diff --git a/CHANGELOG.md b/CHANGELOG.md
index f5cbc7eaa..ce9b3cb8d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [0.18.10] - 2026-04-24
+
+### Added
+
+- **`interfaces.Troubleshooter`** optional interface on `ResourceDriver`. Drivers implementing it
+ return structured `[]Diagnostic` (phase, cause, timestamp, log-tail detail) that wfctl renders
+ in CI output on deploy/apply failure. No changes required for drivers that don't implement it;
+ `codes.Unimplemented` is swallowed silently.
+- **`cmd/wfctl/ci_output.go`**: CI-provider detection (GitHub Actions, GitLab CI, Jenkins, CircleCI)
+ with grouped output via provider-native markers (`::group::`, `section_start`, dashed separators).
+- **`cmd/wfctl/ci_output_summary.go`**: Markdown step-summary writer for
+ `$GITHUB_STEP_SUMMARY` (and equivalents) with resource, root cause, phase timings,
+ and collapsible per-diagnostic log tails.
+
+### Changed
+
+- `wfctl ci run --phase deploy` and `wfctl infra apply` automatically invoke
+ `Troubleshooter.Troubleshoot` on any terminal failure (health-check timeout or driver
+ error), render diagnostics, and write the step-summary. Original exit codes and error
+ messages are preserved (observability is additive).
+
+### Not yet
+
+- Generic `IaCProvider.StreamLogs` for real-time log display during builds — deferred to
+ v0.19.0 alongside plugin-manifest split (#42) and provider-agnostic CI summary (#63).
+- AWS, GCP, Azure, tofu Troubleshoot implementations — DO only in v0.7.8; others no-op.
+
## [0.18.9] - 2026-04-24
### Fixed
diff --git a/cmd/wfctl/ci_output.go b/cmd/wfctl/ci_output.go
new file mode 100644
index 000000000..513dc7bd2
--- /dev/null
+++ b/cmd/wfctl/ci_output.go
@@ -0,0 +1,86 @@
+package main
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "time"
+)
+
+// CIGroupEmitter wraps output with CI-provider-specific group markers so
+// long outputs (like diagnostics) render as collapsible sections in the UI.
+type CIGroupEmitter interface {
+ GroupStart(w io.Writer, name string)
+ GroupEnd(w io.Writer)
+ // SummaryPath returns the path to append Markdown summary content, or
+ // "" if this CI provider has no step-summary concept.
+ SummaryPath() string
+}
+
+// detectCIProvider inspects env vars and returns the appropriate emitter.
+func detectCIProvider() CIGroupEmitter {
+ switch {
+ case os.Getenv("GITHUB_ACTIONS") == "true":
+ return githubEmitter{summaryPath: os.Getenv("GITHUB_STEP_SUMMARY")}
+ case os.Getenv("GITLAB_CI") == "true":
+ return &gitlabEmitter{}
+ case os.Getenv("JENKINS_HOME") != "":
+ return jenkinsEmitter{}
+ case os.Getenv("CIRCLECI") == "true":
+ return circleCIEmitter{}
+ default:
+ return plainEmitter{}
+ }
+}
+
+// --- GitHub Actions ---
+
+type githubEmitter struct{ summaryPath string }
+
+func (g githubEmitter) GroupStart(w io.Writer, name string) {
+ fmt.Fprintf(w, "::group::%s\n", name)
+}
+func (g githubEmitter) GroupEnd(w io.Writer) { fmt.Fprintln(w, "::endgroup::") }
+func (g githubEmitter) SummaryPath() string { return g.summaryPath }
+
+// --- GitLab CI ---
+
+// gitlabEmitter stores the section ID generated in GroupStart so that
+// GroupEnd can emit a matching ID — GitLab requires identical IDs on both
+// markers to close the fold correctly.
+type gitlabEmitter struct {
+ sectionID string
+}
+
+func (g *gitlabEmitter) GroupStart(w io.Writer, name string) {
+ g.sectionID = fmt.Sprintf("wfctl_%d", time.Now().UnixNano())
+ fmt.Fprintf(w, "\x1b[0Ksection_start:%d:%s\r\x1b[0K%s\n", time.Now().Unix(), g.sectionID, name)
+}
+func (g *gitlabEmitter) GroupEnd(w io.Writer) {
+ fmt.Fprintf(w, "\x1b[0Ksection_end:%d:%s\r\x1b[0K\n", time.Now().Unix(), g.sectionID)
+}
+func (g *gitlabEmitter) SummaryPath() string { return "" }
+
+// --- Jenkins ---
+
+type jenkinsEmitter struct{}
+
+func (j jenkinsEmitter) GroupStart(w io.Writer, name string) { fmt.Fprintf(w, "\n--- %s ---\n", name) }
+func (j jenkinsEmitter) GroupEnd(w io.Writer) { fmt.Fprintln(w, "--- end ---") }
+func (j jenkinsEmitter) SummaryPath() string { return "" }
+
+// --- CircleCI ---
+
+type circleCIEmitter struct{}
+
+func (c circleCIEmitter) GroupStart(w io.Writer, name string) { fmt.Fprintf(w, "\n--- %s ---\n", name) }
+func (c circleCIEmitter) GroupEnd(w io.Writer) { fmt.Fprintln(w, "--- end ---") }
+func (c circleCIEmitter) SummaryPath() string { return "" }
+
+// --- Plain (default) ---
+
+type plainEmitter struct{}
+
+func (p plainEmitter) GroupStart(w io.Writer, name string) { fmt.Fprintf(w, "\n--- %s ---\n", name) }
+func (p plainEmitter) GroupEnd(w io.Writer) { fmt.Fprintln(w, "--- end ---") }
+func (p plainEmitter) SummaryPath() string { return "" }
diff --git a/cmd/wfctl/ci_output_summary.go b/cmd/wfctl/ci_output_summary.go
new file mode 100644
index 000000000..ffeae1ffb
--- /dev/null
+++ b/cmd/wfctl/ci_output_summary.go
@@ -0,0 +1,77 @@
+package main
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/GoCodeAlone/workflow/interfaces"
+)
+
+// SummaryInput is the data bundled into a step-summary Markdown block.
+type SummaryInput struct {
+ Operation string // "deploy" | "apply" | "destroy"
+ Env string // e.g. "staging"
+ Resource string // resource display name
+ Outcome string // "SUCCESS" | "FAILED"
+ ConsoleURL string // direct link to provider dashboard
+ Diagnostics []interfaces.Diagnostic
+ Phases []PhaseTiming
+ RootCause string
+}
+
+// PhaseTiming records the name, status, and duration of a deployment phase.
+type PhaseTiming struct {
+ Name string
+ Status string
+ Duration time.Duration
+}
+
+// WriteStepSummary appends Markdown to the CI provider's summary destination.
+// No-ops when the provider has no summary destination (all non-GHA for now).
+func WriteStepSummary(emitter CIGroupEmitter, in SummaryInput) error {
+ path := emitter.SummaryPath()
+ if path == "" {
+ return nil
+ }
+ f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) //nolint:gosec // summary path comes from GITHUB_STEP_SUMMARY env var, not user input
+ if err != nil {
+ return fmt.Errorf("open summary: %w", err)
+ }
+ defer f.Close()
+ return renderSummary(f, in)
+}
+
+func renderSummary(w io.Writer, in SummaryInput) error {
+ var b strings.Builder
+ fmt.Fprintf(&b, "## wfctl: %s %s — %s\n\n", in.Operation, in.Env, in.Outcome)
+ if in.Resource != "" {
+ fmt.Fprintf(&b, "**Resource:** %s\n", in.Resource)
+ }
+ if in.RootCause != "" {
+ fmt.Fprintf(&b, "**Root cause:** `%s`\n", in.RootCause)
+ }
+ if in.ConsoleURL != "" {
+ fmt.Fprintf(&b, "**Console:** %s\n", in.ConsoleURL)
+ }
+ if len(in.Phases) > 0 {
+ b.WriteString("\n### Phase timings\n\n| Phase | Status | Duration |\n|---|---|---|\n")
+ for _, p := range in.Phases {
+ fmt.Fprintf(&b, "| %s | %s | %s |\n", p.Name, p.Status, p.Duration.Round(time.Second))
+ }
+ }
+ if len(in.Diagnostics) > 0 {
+ b.WriteString("\n### Diagnostics\n\n")
+ for _, d := range in.Diagnostics {
+ fmt.Fprintf(&b, "- **[%s]** `%s` — %s\n", d.Phase, d.ID, d.Cause)
+ if d.Detail != "" {
+ fmt.Fprintf(&b, " log tail
\n\n ```\n %s\n ```\n\n \n",
+ strings.ReplaceAll(d.Detail, "\n", "\n "))
+ }
+ }
+ }
+ _, err := io.WriteString(w, b.String())
+ return err
+}
diff --git a/cmd/wfctl/ci_output_summary_test.go b/cmd/wfctl/ci_output_summary_test.go
new file mode 100644
index 000000000..0f548e155
--- /dev/null
+++ b/cmd/wfctl/ci_output_summary_test.go
@@ -0,0 +1,110 @@
+package main
+
+import (
+ "bytes"
+ "flag"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/GoCodeAlone/workflow/interfaces"
+)
+
+var updateGolden = flag.Bool("update-golden", false, "update golden files")
+
+func TestRenderSummary_Failure_Golden(t *testing.T) {
+ in := SummaryInput{
+ Operation: "deploy",
+ Env: "staging",
+ Resource: "bmw-staging",
+ Outcome: "FAILED",
+ ConsoleURL: "https://cloud.digitalocean.com/apps/abc",
+ RootCause: "workflow-migrate up: first .: file does not exist",
+ Phases: []PhaseTiming{
+ {Name: "build", Status: "SUCCESS", Duration: 134 * time.Second},
+ {Name: "pre_deploy", Status: "ERROR", Duration: 3 * time.Second},
+ },
+ Diagnostics: []interfaces.Diagnostic{
+ {ID: "dep-123", Phase: "pre_deploy", Cause: "exit status 1",
+ At: mustTime("2026-04-24T17:42:45Z"),
+ Detail: "workflow-migrate up: first .: file does not exist\nError: exit status 1"},
+ },
+ }
+ var got bytes.Buffer
+ if err := renderSummary(&got, in); err != nil {
+ t.Fatal(err)
+ }
+ compareGolden(t, "summary_failure.golden.md", got.String())
+}
+
+func TestRenderSummary_Success_Golden(t *testing.T) {
+ in := SummaryInput{
+ Operation: "deploy", Env: "staging", Resource: "bmw-staging",
+ Outcome: "SUCCESS", ConsoleURL: "https://cloud.digitalocean.com/apps/abc",
+ Phases: []PhaseTiming{
+ {Name: "build", Status: "SUCCESS", Duration: 134 * time.Second},
+ {Name: "pre_deploy", Status: "SUCCESS", Duration: 12 * time.Second},
+ {Name: "deploy", Status: "SUCCESS", Duration: 45 * time.Second},
+ },
+ }
+ var got bytes.Buffer
+ if err := renderSummary(&got, in); err != nil {
+ t.Fatal(err)
+ }
+ compareGolden(t, "summary_success.golden.md", got.String())
+}
+
+func TestWriteStepSummary_NoPathNoop(t *testing.T) {
+ e := plainEmitter{}
+ if err := WriteStepSummary(e, SummaryInput{}); err != nil {
+ t.Fatalf("plain emitter should noop, got err: %v", err)
+ }
+}
+
+func TestWriteStepSummary_GHA_AppendsToFile(t *testing.T) {
+ tmp := t.TempDir()
+ path := filepath.Join(tmp, "summary.md")
+ e := githubEmitter{summaryPath: path}
+ if err := WriteStepSummary(e, SummaryInput{
+ Operation: "apply", Env: "staging", Outcome: "SUCCESS",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Contains(data, []byte("## wfctl: apply staging — SUCCESS")) {
+ t.Errorf("summary missing header: %q", data)
+ }
+}
+
+func mustTime(s string) time.Time {
+ t, err := time.Parse(time.RFC3339, s)
+ if err != nil {
+ panic(err)
+ }
+ return t
+}
+
+func compareGolden(t *testing.T, name, got string) {
+ t.Helper()
+ path := filepath.Join("testdata", name)
+ if *updateGolden {
+ if err := os.MkdirAll("testdata", 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte(got), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ return
+ }
+ want, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("read golden: %v (run with -update-golden)", err)
+ }
+ if got != string(want) {
+ t.Errorf("golden mismatch in %s\ngot:\n%s\nwant:\n%s", name, got, want)
+ }
+}
diff --git a/cmd/wfctl/ci_output_test.go b/cmd/wfctl/ci_output_test.go
new file mode 100644
index 000000000..a0851764f
--- /dev/null
+++ b/cmd/wfctl/ci_output_test.go
@@ -0,0 +1,90 @@
+package main
+
+import (
+ "bytes"
+ "regexp"
+ "strings"
+ "testing"
+)
+
+func TestDetectCIProvider_GitHub(t *testing.T) {
+ t.Setenv("GITHUB_ACTIONS", "true")
+ t.Setenv("GITLAB_CI", "")
+ t.Setenv("JENKINS_HOME", "")
+ t.Setenv("CIRCLECI", "")
+ e := detectCIProvider()
+ if _, ok := e.(githubEmitter); !ok {
+ t.Fatalf("expected githubEmitter, got %T", e)
+ }
+}
+
+func TestDetectCIProvider_GitLab(t *testing.T) {
+ t.Setenv("GITHUB_ACTIONS", "")
+ t.Setenv("GITLAB_CI", "true")
+ e := detectCIProvider()
+ if _, ok := e.(*gitlabEmitter); !ok {
+ t.Fatalf("expected *gitlabEmitter, got %T", e)
+ }
+}
+
+func TestDetectCIProvider_Default(t *testing.T) {
+ t.Setenv("GITHUB_ACTIONS", "")
+ t.Setenv("GITLAB_CI", "")
+ t.Setenv("JENKINS_HOME", "")
+ t.Setenv("CIRCLECI", "")
+ e := detectCIProvider()
+ if _, ok := e.(plainEmitter); !ok {
+ t.Fatalf("expected plainEmitter, got %T", e)
+ }
+}
+
+func TestGithubEmitter_GroupMarkers(t *testing.T) {
+ var buf bytes.Buffer
+ e := githubEmitter{}
+ e.GroupStart(&buf, "Troubleshoot: bmw-staging")
+ buf.WriteString("hello\n")
+ e.GroupEnd(&buf)
+ out := buf.String()
+ if !strings.Contains(out, "::group::Troubleshoot: bmw-staging\n") {
+ t.Errorf("missing ::group:: marker: %q", out)
+ }
+ if !strings.Contains(out, "::endgroup::\n") {
+ t.Errorf("missing ::endgroup:: marker: %q", out)
+ }
+}
+
+func TestGitlabEmitter_GroupMarkers(t *testing.T) {
+ var buf bytes.Buffer
+ e := &gitlabEmitter{}
+ e.GroupStart(&buf, "my-section")
+ e.GroupEnd(&buf)
+ out := buf.String()
+
+ // Verify both markers are present and share the same section ID so GitLab
+ // correctly closes the fold (mismatched IDs leave sections open forever).
+ startRe := regexp.MustCompile(`section_start:\d+:(\S+)`)
+ endRe := regexp.MustCompile(`section_end:\d+:(\S+)`)
+
+ sm := startRe.FindStringSubmatch(out)
+ if sm == nil {
+ t.Fatalf("missing section_start: %q", out)
+ }
+ em := endRe.FindStringSubmatch(out)
+ if em == nil {
+ t.Fatalf("missing section_end: %q", out)
+ }
+ if sm[1] != em[1] {
+ t.Errorf("section ID mismatch: start=%q end=%q — GitLab won't close the fold", sm[1], em[1])
+ }
+}
+
+func TestPlainEmitter_UsesDashSeparators(t *testing.T) {
+ var buf bytes.Buffer
+ e := plainEmitter{}
+ e.GroupStart(&buf, "section")
+ e.GroupEnd(&buf)
+ out := buf.String()
+ if !strings.Contains(out, "--- section ---") {
+ t.Errorf("expected --- section --- header, got %q", out)
+ }
+}
diff --git a/cmd/wfctl/deploy_providers.go b/cmd/wfctl/deploy_providers.go
index 7fea7c9d6..53b6acc98 100644
--- a/cmd/wfctl/deploy_providers.go
+++ b/cmd/wfctl/deploy_providers.go
@@ -20,6 +20,8 @@ import (
"github.com/GoCodeAlone/workflow/config"
"github.com/GoCodeAlone/workflow/interfaces"
"github.com/GoCodeAlone/workflow/plugin/external"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
)
// DeployConfig holds all parameters needed to execute a deployment.
@@ -683,6 +685,48 @@ func (d *remoteResourceDriver) SensitiveKeys() []string {
return keys
}
+// Troubleshoot calls the plugin's optional Troubleshooter.Troubleshoot.
+// Returns (nil, nil) silently when the plugin returns Unimplemented so
+// the caller doesn't need to probe for capability — absence is a valid answer.
+func (d *remoteResourceDriver) Troubleshoot(ctx context.Context, ref interfaces.ResourceRef, failureMsg string) ([]interfaces.Diagnostic, error) {
+ res, err := d.invoker.InvokeService("ResourceDriver.Troubleshoot", map[string]any{
+ "ref": ref,
+ "failure_msg": failureMsg,
+ })
+ if err != nil {
+ if st, ok := status.FromError(err); ok && st.Code() == codes.Unimplemented {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("resource driver Troubleshoot: %w", err)
+ }
+ raw, _ := res["diagnostics"].([]any)
+ out := make([]interfaces.Diagnostic, 0, len(raw))
+ for _, r := range raw {
+ m, _ := r.(map[string]any)
+ diag := interfaces.Diagnostic{
+ ID: stringVal(m, "id"),
+ Phase: stringVal(m, "phase"),
+ Cause: stringVal(m, "cause"),
+ Detail: stringVal(m, "detail"),
+ }
+ if s := stringVal(m, "at"); s != "" {
+ if t, perr := time.Parse(time.RFC3339, s); perr == nil {
+ diag.At = t
+ }
+ }
+ out = append(out, diag)
+ }
+ return out, nil
+}
+
+// stringVal returns a string field from a map or "" if missing/wrong type.
+func stringVal(m map[string]any, k string) string {
+ if v, ok := m[k].(string); ok {
+ return v
+ }
+ return ""
+}
+
func stringFromMap(m map[string]any, key string) string {
v, _ := m[key].(string)
return v
@@ -1046,9 +1090,18 @@ func (p *pluginDeployProvider) HealthCheck(ctx context.Context, cfg DeployConfig
return nil
}
+// healthPollProgressInterval is how often a "still waiting" heartbeat is printed
+// when no new status message has arrived.
+var healthPollProgressInterval = 30 * time.Second
+
// pollUntilHealthy polls driver.HealthCheck until Healthy=true, context cancels,
// or the default timeout elapses. ErrTransient/ErrRateLimited are treated as
// "keep polling"; any other error fails fast.
+//
+// Progress lines are emitted on every status change and at least every
+// healthPollProgressInterval so the user can see the deploy is still running.
+// On timeout, if the driver implements interfaces.Troubleshooter, recent
+// provider-side events are fetched and printed in a structured failure block.
func pollUntilHealthy(ctx context.Context, driver interfaces.ResourceDriver, ref interfaces.ResourceRef, name string) error {
deadline := time.Now().Add(healthPollDefaultTimeout)
pollCtx, cancel := context.WithDeadline(ctx, deadline)
@@ -1056,6 +1109,20 @@ func pollUntilHealthy(ctx context.Context, driver interfaces.ResourceDriver, ref
start := time.Now()
var lastMsg string
+ lastProgress := start // initialise to start so the first heartbeat fires after healthPollProgressInterval
+
+ fmt.Printf(" → health poll: waiting for %q to become healthy (timeout: %s)\n", name, healthPollDefaultTimeout)
+
+ emitProgress := func(msg string) {
+ elapsed := time.Since(start).Round(time.Second)
+ ts := time.Now().Format("15:04:05")
+ if msg != "" {
+ fmt.Printf(" [%s] health poll %q: %s (%s elapsed)\n", ts, name, msg, elapsed)
+ } else {
+ fmt.Printf(" [%s] health poll %q: still waiting (%s elapsed)\n", ts, name, elapsed)
+ }
+ lastProgress = time.Now()
+ }
for {
result, hcErr := driver.HealthCheck(pollCtx, ref)
@@ -1067,9 +1134,14 @@ func pollUntilHealthy(ctx context.Context, driver interfaces.ResourceDriver, ref
}
log.Printf("plugin health check %q: transient error, continuing poll: %v", name, hcErr)
case result.Healthy:
+ elapsed := time.Since(start).Round(time.Second)
+ fmt.Printf(" [%s] health poll %q: ✓ healthy (%s)\n", time.Now().Format("15:04:05"), name, elapsed)
return nil
default:
- lastMsg = result.Message
+ if result.Message != lastMsg {
+ lastMsg = result.Message
+ emitProgress(lastMsg)
+ }
}
// Choose poll interval based on elapsed time.
@@ -1080,21 +1152,88 @@ func pollUntilHealthy(ctx context.Context, driver interfaces.ResourceDriver, ref
select {
case <-pollCtx.Done():
- if lastMsg != "" {
- return fmt.Errorf("plugin health check %q: timed out waiting for healthy; last status: %s", name, lastMsg)
+ // Distinguish parent-cancel (Ctrl-C / pipeline abort) from our own deadline.
+ if errors.Is(pollCtx.Err(), context.Canceled) {
+ return fmt.Errorf("plugin health check %q: cancelled", name)
}
- return fmt.Errorf("plugin health check %q: timed out waiting for healthy", name)
+ return healthPollTimeout(ctx, driver, ref, name, lastMsg, start)
case <-time.After(interval):
}
// Check again after sleeping (context may have expired during sleep).
if pollCtx.Err() != nil {
- if lastMsg != "" {
- return fmt.Errorf("plugin health check %q: timed out waiting for healthy; last status: %s", name, lastMsg)
+ if errors.Is(pollCtx.Err(), context.Canceled) {
+ return fmt.Errorf("plugin health check %q: cancelled", name)
+ }
+ return healthPollTimeout(ctx, driver, ref, name, lastMsg, start)
+ }
+
+ // Emit a heartbeat if nothing has been logged recently.
+ if time.Since(lastProgress) >= healthPollProgressInterval {
+ emitProgress(lastMsg)
+ }
+ }
+}
+
+// healthPollTimeout builds the timeout error, emits a structured failure block,
+// and auto-troubleshoots via the driver's Troubleshooter (if any) before returning.
+func healthPollTimeout(ctx context.Context, driver interfaces.ResourceDriver, ref interfaces.ResourceRef, name, lastMsg string, start time.Time) error {
+ elapsed := time.Since(start).Round(time.Second)
+
+ // Keep the returned error text identical to the pre-v0.18.10 format so
+ // grep-based CI parsers are not broken (observability is additive).
+ baseErr := fmt.Sprintf("plugin health check %q: timed out waiting for healthy", name)
+ if lastMsg != "" {
+ baseErr = fmt.Sprintf("%s; last status: %s", baseErr, lastMsg)
+ }
+
+ // Print structured failure block (elapsed only in the human-readable output).
+ fmt.Fprintf(os.Stderr, "\n❌ Deploy health check timed out for %q after %s\n", name, elapsed)
+ if lastMsg != "" {
+ fmt.Fprintf(os.Stderr, " Last observed status: %s\n", lastMsg)
+ }
+ fmt.Fprintln(os.Stderr)
+
+ em := detectCIProvider()
+ troubleshootAfterFailure(ctx, os.Stderr, driver, ref, errors.New(baseErr), 30*time.Second, em)
+
+ return errors.New(baseErr)
+}
+
+// emitDiagnostics renders diagnostics into a CI group block on w.
+// No-op when diags is empty.
+func emitDiagnostics(w io.Writer, resource string, diags []interfaces.Diagnostic, em CIGroupEmitter) {
+ if len(diags) == 0 {
+ return
+ }
+ em.GroupStart(w, fmt.Sprintf("Troubleshoot: %s", resource))
+ for _, d := range diags {
+ fmt.Fprintf(w, " [%s] %s — %s (at %s)\n", d.Phase, d.ID, d.Cause, d.At.Format(time.RFC3339))
+ if d.Detail != "" {
+ for _, line := range strings.Split(strings.TrimRight(d.Detail, "\n"), "\n") {
+ fmt.Fprintf(w, " %s\n", line)
}
- return fmt.Errorf("plugin health check %q: timed out waiting for healthy", name)
}
}
+ em.GroupEnd(w)
+}
+
+// troubleshootAfterFailure probes driver for Troubleshooter, calls it with a bounded
+// timeout, and renders diagnostics via the provided emitter. All errors are swallowed —
+// observability is additive; it never masks the original failure.
+func troubleshootAfterFailure(ctx context.Context, w io.Writer, driver interface{}, ref interfaces.ResourceRef, origErr error, timeout time.Duration, em CIGroupEmitter) {
+ ts, ok := driver.(interfaces.Troubleshooter)
+ if !ok {
+ return
+ }
+ tsCtx, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+ diags, err := ts.Troubleshoot(tsCtx, ref, origErr.Error())
+ if err != nil {
+ log.Printf("troubleshoot: %v (ignored)", err)
+ return
+ }
+ emitDiagnostics(w, ref.Name, diags, em)
}
// ── kubernetes provider ───────────────────────────────────────────────────────
diff --git a/cmd/wfctl/deploy_providers_remote_driver_test.go b/cmd/wfctl/deploy_providers_remote_driver_test.go
index 44562d85f..728536a46 100644
--- a/cmd/wfctl/deploy_providers_remote_driver_test.go
+++ b/cmd/wfctl/deploy_providers_remote_driver_test.go
@@ -8,6 +8,8 @@ import (
"testing"
"github.com/GoCodeAlone/workflow/interfaces"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
)
// stubInvoker is a test double for remoteServiceInvoker that records calls
@@ -539,6 +541,57 @@ func TestRemoteDriver_HealthCheck_WrapsAllSentinels(t *testing.T) {
}
}
+// ── Troubleshoot ──────────────────────────────────────────────────────────────
+
+func TestRemoteDriver_Troubleshoot_Success(t *testing.T) {
+ si := &stubInvoker{
+ resp: map[string]any{
+ "diagnostics": []any{
+ map[string]any{
+ "id": "dep-1", "phase": "pre_deploy",
+ "cause": "exit 1", "at": "2026-04-24T00:00:00Z",
+ "detail": "log tail",
+ },
+ },
+ },
+ }
+ d := newDriver(si)
+ diags, err := d.Troubleshoot(context.Background(), interfaces.ResourceRef{Name: "x"}, "boom")
+ if err != nil {
+ t.Fatalf("unexpected: %v", err)
+ }
+ if len(diags) != 1 || diags[0].Cause != "exit 1" {
+ t.Fatalf("unexpected diags: %+v", diags)
+ }
+ if si.method != "ResourceDriver.Troubleshoot" {
+ t.Errorf("wrong svc: %s", si.method)
+ }
+ if diags[0].Detail != "log tail" {
+ t.Errorf("Detail: got %q", diags[0].Detail)
+ }
+}
+
+func TestRemoteDriver_Troubleshoot_UnimplementedSilent(t *testing.T) {
+ si := &stubInvoker{err: status.Error(codes.Unimplemented, "method not implemented")}
+ d := newDriver(si)
+ diags, err := d.Troubleshoot(context.Background(), interfaces.ResourceRef{Name: "x"}, "boom")
+ if err != nil {
+ t.Fatalf("Unimplemented should not surface: %v", err)
+ }
+ if diags != nil {
+ t.Fatalf("expected nil diags, got %+v", diags)
+ }
+}
+
+func TestRemoteDriver_Troubleshoot_OtherErrorSurfaces(t *testing.T) {
+ si := &stubInvoker{err: errors.New("network oops")}
+ d := newDriver(si)
+ _, err := d.Troubleshoot(context.Background(), interfaces.ResourceRef{Name: "x"}, "boom")
+ if err == nil {
+ t.Fatal("expected error to surface")
+ }
+}
+
func TestRemoteDriver_PassThroughUnknownErrors(t *testing.T) {
msg := "connection reset by peer"
for _, method := range []string{"create", "read", "update", "delete", "diff", "scale", "hc"} {
diff --git a/cmd/wfctl/deploy_providers_troubleshoot_test.go b/cmd/wfctl/deploy_providers_troubleshoot_test.go
new file mode 100644
index 000000000..e492781de
--- /dev/null
+++ b/cmd/wfctl/deploy_providers_troubleshoot_test.go
@@ -0,0 +1,77 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/GoCodeAlone/workflow/interfaces"
+)
+
+type fakeTroubleshootingDriver struct {
+ interfaces.ResourceDriver // embed for forward-compatibility; nil is fine for tests
+ diags []interfaces.Diagnostic
+ err error
+ calls int
+}
+
+func (f *fakeTroubleshootingDriver) Troubleshoot(_ context.Context, _ interfaces.ResourceRef, _ string) ([]interfaces.Diagnostic, error) {
+ f.calls++
+ return f.diags, f.err
+}
+
+func TestEmitDiagnostics_WritesGroupBlock(t *testing.T) {
+ t.Setenv("GITHUB_ACTIONS", "true")
+ var buf bytes.Buffer
+ diags := []interfaces.Diagnostic{
+ {ID: "dep-1", Phase: "pre_deploy", Cause: "exit 1",
+ At: mustTime("2026-04-24T00:00:00Z"),
+ Detail: "migration failed"},
+ }
+ emitDiagnostics(&buf, "bmw-staging", diags, detectCIProvider())
+ out := buf.String()
+ if !strings.Contains(out, "::group::Troubleshoot: bmw-staging") {
+ t.Errorf("missing group marker: %q", out)
+ }
+ if !strings.Contains(out, "[pre_deploy]") || !strings.Contains(out, "exit 1") {
+ t.Errorf("missing diagnostic body: %q", out)
+ }
+ if !strings.Contains(out, "::endgroup::") {
+ t.Errorf("missing endgroup: %q", out)
+ }
+}
+
+func TestEmitDiagnostics_EmptyIsNoop(t *testing.T) {
+ var buf bytes.Buffer
+ emitDiagnostics(&buf, "x", nil, plainEmitter{})
+ if buf.Len() != 0 {
+ t.Errorf("expected no output for empty diags, got %q", buf.String())
+ }
+}
+
+func TestTroubleshootAfterFailure_Timeout(t *testing.T) {
+ f := &fakeTroubleshootingDriver{
+ diags: []interfaces.Diagnostic{{ID: "d", Phase: "run", Cause: "ouch", At: mustTime("2026-04-24T00:00:00Z")}},
+ }
+ var buf bytes.Buffer
+ origErr := errors.New("plugin health check \"bmw-staging\": timed out waiting for healthy")
+ troubleshootAfterFailure(context.Background(), &buf, f, interfaces.ResourceRef{Name: "bmw-staging"}, origErr, 30*time.Second, plainEmitter{})
+ if f.calls != 1 {
+ t.Errorf("Troubleshoot not called: calls=%d", f.calls)
+ }
+ if !strings.Contains(buf.String(), "ouch") {
+ t.Errorf("missing Cause in output: %q", buf.String())
+ }
+}
+
+func TestTroubleshootAfterFailure_NonTroubleshooterSkips(t *testing.T) {
+ var buf bytes.Buffer
+ type plainDriver struct{ interfaces.ResourceDriver }
+ troubleshootAfterFailure(context.Background(), &buf, &plainDriver{}, interfaces.ResourceRef{}, errors.New("x"), 30*time.Second, plainEmitter{})
+ if buf.Len() != 0 {
+ t.Errorf("non-troubleshooter should not produce output: %q", buf.String())
+ }
+}
diff --git a/cmd/wfctl/e2e_deploy_troubleshoot_test.go b/cmd/wfctl/e2e_deploy_troubleshoot_test.go
new file mode 100644
index 000000000..ceaf630e4
--- /dev/null
+++ b/cmd/wfctl/e2e_deploy_troubleshoot_test.go
@@ -0,0 +1,57 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/GoCodeAlone/workflow/interfaces"
+)
+
+func TestE2EDeployFailure_EmitsFullTroubleshootBlock(t *testing.T) {
+ t.Setenv("GITHUB_ACTIONS", "true")
+ summary := t.TempDir() + "/summary.md"
+ t.Setenv("GITHUB_STEP_SUMMARY", summary)
+
+ diags := []interfaces.Diagnostic{
+ {ID: "dep-1", Phase: "pre_deploy", Cause: "migration failed",
+ At: mustTime("2026-04-24T17:42:45Z"),
+ Detail: "exit status 1"},
+ {ID: "dep-1", Phase: "build", Cause: "",
+ At: mustTime("2026-04-24T17:40:00Z"), Detail: ""},
+ }
+ f := &fakeTroubleshootingDriver{diags: diags}
+ var stderr bytes.Buffer
+ em := detectCIProvider()
+ troubleshootAfterFailure(context.Background(), &stderr, f, interfaces.ResourceRef{Name: "bmw-staging"},
+ errors.New("timed out"), 30*time.Second, em)
+ if f.calls != 1 {
+ t.Fatal("Troubleshoot not called")
+ }
+ out := stderr.String()
+ if !strings.Contains(out, "::group::Troubleshoot: bmw-staging") {
+ t.Error("missing group marker")
+ }
+ if !strings.Contains(out, "[pre_deploy]") {
+ t.Error("missing phase marker")
+ }
+ if !strings.Contains(out, "migration failed") {
+ t.Error("missing cause")
+ }
+ if !strings.Contains(out, "exit status 1") {
+ t.Error("missing detail")
+ }
+}
+
+func TestE2ENoTroubleshooter_NoCrash(t *testing.T) {
+ var stderr bytes.Buffer
+ type plainDriver struct{ interfaces.ResourceDriver }
+ troubleshootAfterFailure(context.Background(), &stderr, &plainDriver{}, interfaces.ResourceRef{},
+ errors.New("x"), 30*time.Second, plainEmitter{})
+ if stderr.Len() != 0 {
+ t.Errorf("unexpected output: %q", stderr.String())
+ }
+}
diff --git a/cmd/wfctl/infra_apply.go b/cmd/wfctl/infra_apply.go
index 6b60a92b0..bf26a131d 100644
--- a/cmd/wfctl/infra_apply.go
+++ b/cmd/wfctl/infra_apply.go
@@ -3,6 +3,7 @@ package main
import (
"context"
"fmt"
+ "io"
"os"
"strings"
"time"
@@ -12,6 +13,10 @@ import (
"github.com/GoCodeAlone/workflow/platform"
)
+// infraApplyTroubleshootTimeout is the budget for a Troubleshoot call when
+// infra apply fails. Kept separate so tests can override it.
+var infraApplyTroubleshootTimeout = 30 * time.Second
+
// hasInfraModules reports whether cfgFile contains any modules with the new
// infra.* type prefix. Used by runInfraApply to select the dispatch path:
// direct IaCProvider path for infra.* configs, pipeline path for legacy
@@ -165,7 +170,7 @@ func applyInfraModules(ctx context.Context, cfgFile, envName string) error { //n
}
}()
}
- return applyWithProviderAndStore(ctx, provider, g.provType, g.specs, current, store)
+ return applyWithProviderAndStore(ctx, provider, g.provType, g.specs, current, store, os.Stderr)
}
for _, moduleRef := range groupOrder {
if err := applyGroup(moduleRef, groups[moduleRef]); err != nil {
@@ -183,8 +188,9 @@ func applyInfraModules(ctx context.Context, cfgFile, envName string) error { //n
//
// providerType is used only as a label when constructing ResourceState records.
// Callers pass a nil store (or noopStateStore) when state persistence is not
-// required.
-func applyWithProviderAndStore(ctx context.Context, provider interfaces.IaCProvider, providerType string, specs []interfaces.ResourceSpec, current []interfaces.ResourceState, store infraStateStore) error {
+// required. w receives diagnostic output; callers typically pass os.Stderr but
+// tests may supply a bytes.Buffer to capture and assert the output.
+func applyWithProviderAndStore(ctx context.Context, provider interfaces.IaCProvider, providerType string, specs []interfaces.ResourceSpec, current []interfaces.ResourceState, store infraStateStore, w io.Writer) error {
if store == nil {
store = &noopStateStore{}
}
@@ -246,6 +252,24 @@ func applyWithProviderAndStore(ctx context.Context, provider interfaces.IaCProvi
fmt.Printf(" Plan: %d action(s) to execute.\n", len(plan.Actions))
result, err := provider.Apply(ctx, &plan)
if err != nil {
+ // Derive the most specific resource ref we can for troubleshooting.
+ // Single-action plans give us an exact resource; multi-resource plans
+ // fall back to the first spec so the troubleshooter has at least a name.
+ ref := interfaces.ResourceRef{}
+ if len(plan.Actions) == 1 {
+ ref.Name = plan.Actions[0].Resource.Name
+ ref.Type = plan.Actions[0].Resource.Type
+ } else if len(specs) == 1 {
+ ref.Name = specs[0].Name
+ ref.Type = specs[0].Type
+ }
+ em := detectCIProvider()
+ // TODO(v0.18.11): provider is interfaces.IaCProvider, which does not implement
+ // interfaces.Troubleshooter. troubleshootAfterFailure's type assertion always
+ // returns false here — this hook is currently a no-op for all IaC providers.
+ // Full diagnostics require per-ResourceDriver access to be threaded through
+ // applyInfraModules (see task #69).
+ troubleshootAfterFailure(ctx, w, provider, ref, err, infraApplyTroubleshootTimeout, em)
return fmt.Errorf("apply: %w", err)
}
if result != nil {
diff --git a/cmd/wfctl/infra_apply_test.go b/cmd/wfctl/infra_apply_test.go
index 55f8c1cce..9749ec2ea 100644
--- a/cmd/wfctl/infra_apply_test.go
+++ b/cmd/wfctl/infra_apply_test.go
@@ -194,7 +194,7 @@ func TestApplyWithProvider_NoChanges(t *testing.T) {
}}
fake := &applyCapture{}
- if err := applyWithProviderAndStore(context.Background(), fake, "fake-cloud", []interfaces.ResourceSpec{spec}, current, nil); err != nil {
+ if err := applyWithProviderAndStore(context.Background(), fake, "fake-cloud", []interfaces.ResourceSpec{spec}, current, nil, io.Discard); err != nil {
t.Fatalf("applyWithProviderAndStore: %v", err)
}
@@ -223,7 +223,7 @@ func TestApplyWithProvider_DeletesRemovedResource(t *testing.T) {
fake := &applyCapture{}
store := &fakeStateStore{}
- if err := applyWithProviderAndStore(context.Background(), fake, "fake-cloud", specs, current, store); err != nil {
+ if err := applyWithProviderAndStore(context.Background(), fake, "fake-cloud", specs, current, store, io.Discard); err != nil {
t.Fatalf("applyWithProviderAndStore: %v", err)
}
@@ -323,7 +323,7 @@ func TestApplyWithProvider_SavesStateForSuccessfulResources(t *testing.T) {
}
store := &fakeStateStore{}
- if err := applyWithProviderAndStore(t.Context(), fake, "fake-cloud", specs, nil, store); err != nil {
+ if err := applyWithProviderAndStore(t.Context(), fake, "fake-cloud", specs, nil, store, io.Discard); err != nil {
t.Fatalf("apply: %v", err)
}
@@ -366,7 +366,7 @@ func TestApplyWithProvider_SavesStateOnPartialFailure(t *testing.T) {
}
store := &fakeStateStore{}
- err := applyWithProviderAndStore(t.Context(), fake, "fake-cloud", specs, nil, store)
+ err := applyWithProviderAndStore(t.Context(), fake, "fake-cloud", specs, nil, store, io.Discard)
if err == nil {
t.Fatal("expected error on partial failure, got nil")
}
@@ -393,7 +393,7 @@ func TestApplyWithProvider_StoreSaveFailureIsNonFatal(t *testing.T) {
store := &fakeStateStore{saveErr: fmt.Errorf("disk full")}
// Should succeed even though SaveResource errors.
- if err := applyWithProviderAndStore(t.Context(), fake, "fake-cloud", specs, nil, store); err != nil {
+ if err := applyWithProviderAndStore(t.Context(), fake, "fake-cloud", specs, nil, store, io.Discard); err != nil {
t.Fatalf("expected no error despite save failure, got: %v", err)
}
}
@@ -509,7 +509,7 @@ func TestApplyInfraModules_CallsResolveSizing_ForEachSpec(t *testing.T) {
},
}
- if err := applyWithProviderAndStore(t.Context(), fake, "fake-cloud", specs, nil, nil); err != nil {
+ if err := applyWithProviderAndStore(t.Context(), fake, "fake-cloud", specs, nil, nil, io.Discard); err != nil {
t.Fatalf("applyWithProviderAndStore: %v", err)
}
diff --git a/cmd/wfctl/infra_apply_troubleshoot_test.go b/cmd/wfctl/infra_apply_troubleshoot_test.go
new file mode 100644
index 000000000..800b486b0
--- /dev/null
+++ b/cmd/wfctl/infra_apply_troubleshoot_test.go
@@ -0,0 +1,101 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/GoCodeAlone/workflow/interfaces"
+)
+
+// applyFailProvider is an IaCProvider that always fails Apply and implements
+// Troubleshooter so we can verify diagnostics are surfaced on failure.
+type applyFailProvider struct {
+ applyCapture
+ applyErr error
+ diags []interfaces.Diagnostic
+ tsErr error
+ tsCalls int
+}
+
+func (p *applyFailProvider) Apply(_ context.Context, plan *interfaces.IaCPlan) (*interfaces.ApplyResult, error) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ p.applyCalled = true
+ p.appliedPlan = plan
+ return nil, p.applyErr
+}
+
+func (p *applyFailProvider) Troubleshoot(_ context.Context, _ interfaces.ResourceRef, _ string) ([]interfaces.Diagnostic, error) {
+ p.tsCalls++
+ return p.diags, p.tsErr
+}
+
+// plainFailProvider is an IaCProvider that always fails Apply but does NOT
+// implement Troubleshooter. Used to verify the non-troubleshooter path is a no-op.
+type plainFailProvider struct {
+ applyCapture
+ applyErr error
+}
+
+func (p *plainFailProvider) Apply(_ context.Context, plan *interfaces.IaCPlan) (*interfaces.ApplyResult, error) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ p.applyCalled = true
+ p.appliedPlan = plan
+ return nil, p.applyErr
+}
+
+func TestInfraApply_EmitsDiagnosticsOnFailure(t *testing.T) {
+ t.Setenv("GITHUB_ACTIONS", "true")
+
+ diags := []interfaces.Diagnostic{
+ {ID: "dep-abc", Phase: "pre_deploy", Cause: "migration failed", At: mustTime("2026-04-24T00:00:00Z")},
+ }
+ provider := &applyFailProvider{
+ applyErr: errors.New("API error"),
+ diags: diags,
+ }
+
+ infraApplyTroubleshootTimeout = 5 * time.Second
+ defer func() { infraApplyTroubleshootTimeout = 30 * time.Second }()
+
+ var diagBuf bytes.Buffer
+ specs := []interfaces.ResourceSpec{{Name: "bmw-staging", Type: "app_platform"}}
+ err := applyWithProviderAndStore(context.Background(), provider, "digitalocean", specs, nil, nil, &diagBuf)
+ if err == nil {
+ t.Fatal("expected error from failing apply")
+ }
+ if !strings.Contains(err.Error(), "API error") {
+ t.Errorf("original error not preserved: %v", err)
+ }
+ if provider.tsCalls != 1 {
+ t.Errorf("Troubleshoot not called: tsCalls=%d", provider.tsCalls)
+ }
+ out := diagBuf.String()
+ if !strings.Contains(out, "::group::") {
+ t.Errorf("expected GHA group marker in diagnostic output, got: %q", out)
+ }
+ if !strings.Contains(out, "migration failed") {
+ t.Errorf("expected diagnostic cause in output, got: %q", out)
+ }
+}
+
+func TestInfraApply_NonTroubleshooterNocrash(t *testing.T) {
+ // plainFailProvider does not implement Troubleshooter — should be a silent no-op.
+ provider := &plainFailProvider{
+ applyErr: errors.New("boom"),
+ }
+ var diagBuf bytes.Buffer
+ specs := []interfaces.ResourceSpec{{Name: "x", Type: "app_platform"}}
+ err := applyWithProviderAndStore(context.Background(), provider, "digitalocean", specs, nil, nil, &diagBuf)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ if diagBuf.Len() != 0 {
+ t.Errorf("non-troubleshooter should produce no diagnostic output, got: %q", diagBuf.String())
+ }
+}
diff --git a/cmd/wfctl/testdata/summary_failure.golden.md b/cmd/wfctl/testdata/summary_failure.golden.md
new file mode 100644
index 000000000..a20e96e0b
--- /dev/null
+++ b/cmd/wfctl/testdata/summary_failure.golden.md
@@ -0,0 +1,24 @@
+## wfctl: deploy staging — FAILED
+
+**Resource:** bmw-staging
+**Root cause:** `workflow-migrate up: first .: file does not exist`
+**Console:** https://cloud.digitalocean.com/apps/abc
+
+### Phase timings
+
+| Phase | Status | Duration |
+|---|---|---|
+| build | SUCCESS | 2m14s |
+| pre_deploy | ERROR | 3s |
+
+### Diagnostics
+
+- **[pre_deploy]** `dep-123` — exit status 1
+ log tail
+
+ ```
+ workflow-migrate up: first .: file does not exist
+ Error: exit status 1
+ ```
+
+
diff --git a/cmd/wfctl/testdata/summary_success.golden.md b/cmd/wfctl/testdata/summary_success.golden.md
new file mode 100644
index 000000000..10850e9b0
--- /dev/null
+++ b/cmd/wfctl/testdata/summary_success.golden.md
@@ -0,0 +1,12 @@
+## wfctl: deploy staging — SUCCESS
+
+**Resource:** bmw-staging
+**Console:** https://cloud.digitalocean.com/apps/abc
+
+### Phase timings
+
+| Phase | Status | Duration |
+|---|---|---|
+| build | SUCCESS | 2m14s |
+| pre_deploy | SUCCESS | 12s |
+| deploy | SUCCESS | 45s |
diff --git a/docs/plans/2026-04-24-wfctl-deploy-log-observability-design.md b/docs/plans/2026-04-24-wfctl-deploy-log-observability-design.md
new file mode 100644
index 000000000..a876fd54b
--- /dev/null
+++ b/docs/plans/2026-04-24-wfctl-deploy-log-observability-design.md
@@ -0,0 +1,246 @@
+# wfctl Deploy-Log Observability — Design
+
+**Status:** Approved (autonomous pipeline, 2026-04-24)
+
+**Target release:** workflow v0.18.10 + workflow-plugin-digitalocean v0.7.8
+
+**Goal:** When a `wfctl infra apply` or `wfctl ci run --phase deploy` fails or its health check times out, wfctl automatically surfaces provider-side context (deployment phase, build/pre-deploy/run logs, root cause) in CI output instead of leaving the operator to hunt in the provider's web console. Structured per-phase output uses CI-provider-agnostic grouping (GitHub Actions `::group::`, GitLab `section_start`, Jenkins/CircleCI plain separators) and writes a summary to `$GITHUB_STEP_SUMMARY` (and equivalents) on completion or failure.
+
+**Why:** BMW staging deploy on 2026-04-24 hung for ten minutes with the single error line `plugin health check "bmw-staging": timed out waiting for healthy; last status: no deployment found`, while DO's App Platform pre-deploy job was silently failing with a migration-format error. wfctl had no visibility past its own health-check poll. User mandate: "we shouldn't have to hunt for this info."
+
+---
+
+## Approach
+
+Introduce an optional **`Troubleshooter`** interface on `ResourceDriver`. Drivers that can explain their own failures (DO App Platform, in this release; AWS ECS, GCP Cloud Run, K8s in future releases) implement it. wfctl calls it automatically after any terminal failure — health check timeout, driver error — and renders the returned `[]Diagnostic` in CI output before the original error.
+
+This is **narrower** than a generic `StreamLogs` API: Troubleshoot returns a *synchronous batch* of recent provider-side events (deployments, job runs, errors), each with enough context — phase, root cause, timestamp, and log tail — to explain the failure. No streaming, no bidirectional protocols, no cross-plugin coordination. The interface is optional; drivers that don't implement it silently pass through (wfctl keeps current behavior).
+
+### Why not streaming?
+
+Streaming logs over go-plugin gRPC requires a new bidirectional method, context cancellation plumbing, and careful test harnesses. The value is real-time display during long builds, which is nice-to-have. The value we actually need — "when something fails, show me why" — is served by a synchronous batch fetched *after* the failure, at roughly the same cost as the existing HealthCheck poll. Defer streaming to v0.19.0 when `IaCProvider.StreamLogs` can be designed alongside the plugin-manifest split (task #42) and provider-agnostic CI summary (task #63).
+
+### Why not a new top-level "log fetch" command?
+
+A separate `wfctl infra logs ` command would duplicate the driver-dispatch plumbing for a use case that's almost always post-failure. Making wfctl call Troubleshoot *automatically* on failure means the common case is zero-config. If ad-hoc log fetching is later useful, a CLI wrapper is a thin shell over the same interface.
+
+---
+
+## Architecture
+
+### Interface (workflow/interfaces)
+
+```go
+// Diagnostic is a single troubleshooting finding returned by a Troubleshooter.
+type Diagnostic struct {
+ ID string `json:"id"` // provider-side id (deployment id, task arn)
+ Phase string `json:"phase"` // terminal or current phase
+ Cause string `json:"cause"` // human-readable root cause
+ At time.Time `json:"at"` // when the event occurred
+ Detail string `json:"detail,omitempty"` // optional verbose tail (log excerpt, stack)
+}
+
+// Troubleshooter is an optional interface on ResourceDriver.
+type Troubleshooter interface {
+ Troubleshoot(ctx context.Context, ref ResourceRef, failureMsg string) ([]Diagnostic, error)
+}
+```
+
+Kept deliberately terse: `ID` is a click-through hint for operators who still want the console; `Phase` is the DO/AWS terminology mapped to driver-agnostic strings (`"pre_deploy"`, `"build"`, `"run"`); `Cause` is the one-liner summary; `At` is the event timestamp; `Detail` is an optional multi-line log tail for the case where the cause alone isn't enough (e.g., a stack trace).
+
+### gRPC plumbing (workflow plugin/grpc)
+
+`Troubleshooter` is optional: dispatcher calls a generic `InvokeMethod("Troubleshoot", ...)` with typed args, checks for an `UNIMPLEMENTED` response, treats it as "driver doesn't support troubleshooting" and silently falls back. This matches the existing pattern for `BootstrapStateBackend` (v0.7.4) and the `Scale` method — no breaking change for plugins that don't implement it.
+
+### DO plugin (workflow-plugin-digitalocean)
+
+`AppPlatformDriver.Troubleshoot(ctx, ref, failureMsg)`:
+
+1. Fetch the app via `client.Get(ctx, ref.ProviderID)`.
+2. Iterate `app.ActiveDeployment`, `app.InProgressDeployment`, `app.PendingDeployment`. Pick whichever is non-nil (prefer InProgress, then Pending, then Active — most recent wins).
+3. Call `client.ListDeployments(ctx, appID)` to get recent deployments.
+4. For each relevant deployment (latest 3), fetch logs:
+ - `GET /v2/apps/{id}/deployments/{dep_id}/logs?type=pre_deploy&follow=false`
+ - `GET /v2/apps/{id}/deployments/{dep_id}/logs?type=build&follow=false`
+ - `GET /v2/apps/{id}/deployments/{dep_id}/logs?type=run&follow=false`
+ - `GET /v2/apps/{id}/deployments/{dep_id}/logs?type=deploy&follow=false`
+5. Synthesize one `Diagnostic` per phase that had activity, with `Detail` = tail of that phase's log (last 100 lines, 4KB cap).
+6. Extract `Cause` from the first non-ACTIVE phase: look for patterns like `exit code N`, `Error:`, `failed to parse`, `connection refused`.
+
+Output ordering: most recent deployment first; within a deployment, phases in causal order (pre_deploy → build → deploy → run).
+
+Resource types other than `infra.app_platform` are out of scope for v0.7.8 — their `Troubleshoot` returns `(nil, nil)` (no findings).
+
+### wfctl call sites (workflow/cmd/wfctl)
+
+Two hook points:
+
+**Hook 1 — `wfctl ci run --phase deploy` (cmd/wfctl/deploy_providers.go)**
+After `pluginDeployProvider.Deploy()` returns error OR `HealthCheck` polling times out, check whether the driver implements Troubleshooter (via gRPC probe). If yes, call `Troubleshoot(ctx, ref, originalErr.Error())` with a 30-second timeout, render diagnostics, then propagate the original error.
+
+**Hook 2 — `wfctl infra apply` (cmd/wfctl/infra_apply.go)**
+When `driver.Apply()` or `HealthCheck()` fails, same logic.
+
+Render helper lives in `cmd/wfctl/ci_output.go` (new file) and wraps the per-CI-provider group emission:
+
+```go
+func EmitDiagnosticGroup(w io.Writer, resource string, diags []Diagnostic) {
+ g := detectCIProvider() // GITHUB_ACTIONS | GITLAB_CI | JENKINS | CIRCLECI | "plain"
+ g.GroupStart(w, fmt.Sprintf("Troubleshoot: %s", resource))
+ for _, d := range diags {
+ fmt.Fprintf(w, " [%s] %s — %s (at %s)\n", d.Phase, d.ID, d.Cause, d.At.Format(time.RFC3339))
+ if d.Detail != "" {
+ fmt.Fprintln(w, " " + strings.ReplaceAll(d.Detail, "\n", "\n "))
+ }
+ }
+ g.GroupEnd(w)
+}
+```
+
+### CI-provider summary (GitHub Step Summary and equivalents)
+
+In addition to streaming diagnostics to stdout, wfctl writes a compact Markdown summary to `$GITHUB_STEP_SUMMARY` at process exit (success OR failure):
+
+```markdown
+## wfctl: deploy staging — FAILED
+
+**Resource:** bmw-staging (digitalocean/app_platform, id=f8b6200c-…)
+**Phase:** pre_deploy — ERROR
+**Root cause:** `workflow-migrate up: first .: file does not exist`
+**Deployment:** https://cloud.digitalocean.com/apps/f8b6200c-…/deployments/abc-123
+
+### Phase timings
+| Phase | Status | Duration |
+|---|---|---|
+| build | SUCCESS | 2m 14s |
+| pre_deploy | ERROR | 0m 03s |
+| deploy | (not reached) | — |
+| run | (not reached) | — |
+
+pre_deploy log tail (12 lines)
+
+```
+workflow-migrate up: first .: file does not exist
+Error: exit status 1
+```
+
+
+```
+
+The summary writer is a thin wrapper:
+- GHA: append to `$GITHUB_STEP_SUMMARY` file (if set)
+- GitLab: emit via `$CI_JOB_ANNOTATIONS` (if set) or `section_start` + plain output
+- Jenkins / CircleCI: plain text to stdout (no native summary mechanism)
+- Local / unknown: skip
+
+### Detection helper
+
+```go
+// detectCIProvider returns the provider-specific group emitter. Defaults to
+// plain-text separators when running outside a known CI.
+func detectCIProvider() CIGroupEmitter {
+ switch {
+ case os.Getenv("GITHUB_ACTIONS") == "true":
+ return githubEmitter{summaryPath: os.Getenv("GITHUB_STEP_SUMMARY")}
+ case os.Getenv("GITLAB_CI") == "true":
+ return gitlabEmitter{}
+ case os.Getenv("JENKINS_HOME") != "":
+ return jenkinsEmitter{}
+ case os.Getenv("CIRCLECI") == "true":
+ return circleCIEmitter{}
+ default:
+ return plainEmitter{}
+ }
+}
+```
+
+---
+
+## Data flow (BMW deploy failure, post-v0.18.10)
+
+```
+wfctl ci run --phase deploy --env staging
+ → pluginDeployProvider.Deploy(…)
+ → driver.Update(ref) → DO CreateDeployment(forceBuild=true) → deploymentID
+ → driver.HealthCheck(ref) → poll Active/InProgress/Pending deployment phase
+ → 10 min → phase="ERROR" (pre_deploy failed)
+ → returns error: "plugin health check: …"
+ → deploy_providers.go catches the error
+ → probe driver for Troubleshooter → true
+ → driver.Troubleshoot(ctx, ref, errMsg)
+ → DO plugin fetches app, picks latest deployment (InProgress or most recent failed)
+ → fetches pre_deploy + build + run logs
+ → synthesizes 3 Diagnostics (pre_deploy ERROR, build SUCCESS, run NOT_STARTED)
+ → wfctl emits ::group:: blocks with each Diagnostic's ID, phase, cause, detail
+ → wfctl writes Markdown summary to $GITHUB_STEP_SUMMARY
+ → wfctl exits with original error + exit code 1
+```
+
+Operator opens the GHA run, sees the collapsed "Troubleshoot: bmw-staging" group, expands it, and immediately reads:
+
+```
+[pre_deploy] dep-abc-123 — workflow-migrate up: first .: file does not exist (at 2026-04-24T17:42:45Z)
+ time="…" level=error msg="golang-migrate up: first .: file does not exist"
+ Error: exit status 1
+ command exited with status 1
+```
+
+Diagnosis time: seconds, not a console trip.
+
+---
+
+## Testing
+
+**DO plugin (workflow-plugin-digitalocean):**
+- Unit: mock `godo.Client` returning canned deployment + logs; assert Diagnostic fields, phase ordering, Cause extraction from common error patterns.
+- Integration: tabletest matrix over pre_deploy failure, build failure, run failure (container exit), timeout during build, multi-deployment history.
+
+**wfctl (workflow):**
+- `TestDeployFailure_EmitsDiagnostics` — fake ResourceDriver implementing Troubleshooter returns known Diagnostics; assert `::group::` blocks and summary file contain expected text.
+- `TestDeployFailure_WithoutTroubleshooter` — fake ResourceDriver with no Troubleshooter; assert wfctl behaves identically to v0.18.9 (no crash, original error preserved).
+- `TestCIProvider_Detection` — env-var matrix for GHA/GitLab/Jenkins/CircleCI/plain.
+- `TestStepSummary_Markdown` — golden-file comparison of rendered summary.
+
+**End-to-end (deferred):** BMW staging deploy post-v0.18.10 bump should surface the pre-deploy migration error in its GHA output within the same run where the deploy fails. Validate manually after release.
+
+---
+
+## Rollout
+
+**Phase 1 — workflow v0.18.10 interface + wfctl wiring:**
+- Commit `Troubleshooter`/`Diagnostic` in `interfaces/iac_resource_driver.go` (already drafted).
+- Add `cmd/wfctl/ci_output.go` (CIGroupEmitter, provider detection, summary writer).
+- Wire Troubleshoot call at deploy-provider and infra-apply failure paths.
+- Tests (unit-only; no DO integration yet).
+- Open PR, review, merge, tag v0.18.10.
+
+**Phase 2 — DO plugin v0.7.8 implementation:**
+- Implement `AppPlatformDriver.Troubleshoot` via godo deployments + logs API.
+- Add gRPC dispatch case (InvokeMethod `"Troubleshoot"`).
+- Unit tests with mocked godo.Client.
+- Open PR, review, merge, tag v0.7.8.
+
+**Phase 3 — BMW consumer bump:**
+- BMW bumps `setup-wfctl` pin to v0.18.10.
+- BMW bumps workflow-plugin-digitalocean pin to v0.7.8.
+- Single PR on BMW repo; no infra.yaml or deploy.yml changes needed — observability is purely additive.
+
+---
+
+## Success criteria
+
+- Running `wfctl ci run --phase deploy` against a DO App Platform target that fails pre_deploy surfaces a clearly-labeled diagnostic block in CI output and a Markdown summary in `$GITHUB_STEP_SUMMARY` with the root-cause error line from the pre_deploy log, without the operator visiting the DO console.
+- Plugins that don't implement Troubleshooter (AWS/GCP/Azure/tofu/ci-generator/supply-chain) continue to work with v0.18.10 without modification.
+- wfctl exit codes and error text for callers relying on grep-based log parsing remain unchanged (observability is additive, not substitutive).
+- BMW retry on v0.18.10 + DO plugin v0.7.8 + still-broken-migrations configuration shows the migration-format error in its GHA output within 30 seconds of the 10-minute health-check timeout.
+
+---
+
+## Non-goals
+
+- Generic `IaCProvider.StreamLogs` interface — deferred to v0.19.0 (bundles with plugin-manifest split, task #42, and CI summary scope, task #63).
+- AWS / GCP / Azure / tofu Troubleshoot implementations — DO only for v0.7.8; other drivers silently no-op.
+- Real-time log display during long builds — synchronous post-failure batch is enough for v0.18.10.
+- State-heal behavior (task #67) — orthogonal; stays in the backlog.
+- Polling deployment logs during the happy path (success case) — only called on failure.
+- Log redaction beyond the existing `SensitiveKeys()` mechanism — Diagnostics surface what the driver knows; if a plugin leaks secrets in `Detail`, that's a plugin-side bug to fix separately.
diff --git a/docs/plans/2026-04-24-wfctl-deploy-log-observability.md b/docs/plans/2026-04-24-wfctl-deploy-log-observability.md
new file mode 100644
index 000000000..2dd8621ea
--- /dev/null
+++ b/docs/plans/2026-04-24-wfctl-deploy-log-observability.md
@@ -0,0 +1,1512 @@
+# wfctl Deploy-Log Observability Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Ship workflow v0.18.10 + workflow-plugin-digitalocean v0.7.8 so that when a `wfctl infra apply` or `wfctl ci run --phase deploy` fails or its health check times out, wfctl automatically fetches provider-side deployment context and renders it to CI output (via `::group::` blocks and `$GITHUB_STEP_SUMMARY`), eliminating the need to visit the provider's web console to diagnose failures.
+
+**Architecture:** Optional `Troubleshooter` interface on `ResourceDriver` (synchronous batch fetch, not streaming). DO plugin implements it by calling `godo` deployments + per-phase logs endpoints. wfctl invokes it automatically on failure paths, renders results via a `CIGroupEmitter` that detects `GITHUB_ACTIONS` / `GITLAB_CI` / `JENKINS_HOME` / `CIRCLECI`. Plugins that don't implement the interface silently no-op via `codes.Unimplemented`.
+
+**Tech Stack:** Go 1.26, HashiCorp go-plugin (gRPC-over-stdio), godo (DO SDK), golang-migrate/goose/atlas (for BMW migrations, downstream consumer).
+
+---
+
+## Repos
+
+- **workflow** (`/Users/jon/workspace/workflow`) — branch `feat/v0.18.10-observability` (base commit `b7d0ac1`, already has Troubleshooter/Diagnostic types in `interfaces/iac_resource_driver.go`)
+- **workflow-plugin-digitalocean** (`/Users/jon/workspace/workflow-plugin-digitalocean`) — branch `feat/v0.7.8-troubleshoot` (base at main `0c11714`)
+- **buymywishlist** (`/Users/jon/workspace/buymywishlist`) — new branch `chore/bump-wfctl-v0.18.10-do-v0.7.8` off main
+
+## Dependency order
+
+```
+Task 1-9 (workflow) → Task 10 (tag v0.18.10)
+ ↓
+Task 11-16 (DO plugin — Task 14 blocked by 10) → Task 17 (tag v0.7.8)
+ ↓
+Task 18-19 (BMW bump — blocked by 17)
+```
+
+Phase 2 tasks 11-13 can start in parallel with Phase 1 work since they don't depend on the v0.18.10 tag — only Task 14 (go.mod bump) waits.
+
+---
+
+## Phase 1 — workflow v0.18.10
+
+### Task 1: Verify Troubleshooter / Diagnostic types committed
+
+**Files:**
+- Verify: `interfaces/iac_resource_driver.go:63-90` (already committed in b7d0ac1)
+- Test: `interfaces/iac_resource_driver_test.go` (create)
+
+**Step 1: Read the existing committed interface**
+
+Run: `git show b7d0ac1 -- interfaces/iac_resource_driver.go`
+Expected: `Diagnostic` struct with `ID`, `Phase`, `Cause`, `At`, and `Detail` fields. `Troubleshooter` interface with one method.
+
+**Note:** The committed version is missing the `Detail string` field specified in the design doc. Add it:
+
+```go
+type Diagnostic struct {
+ ID string `json:"id"`
+ Phase string `json:"phase"`
+ Cause string `json:"cause"`
+ At time.Time `json:"at"`
+ Detail string `json:"detail,omitempty"` // optional verbose tail (log excerpt, stack)
+}
+```
+
+**Step 2: Write a minimal compile-time interface-check test**
+
+Create `interfaces/iac_resource_driver_test.go`:
+
+```go
+package interfaces
+
+import (
+ "context"
+ "testing"
+ "time"
+)
+
+// compile-time check: Troubleshooter is a valid interface.
+var _ Troubleshooter = (*fakeTroubleshooter)(nil)
+
+type fakeTroubleshooter struct{}
+
+func (fakeTroubleshooter) Troubleshoot(_ context.Context, _ ResourceRef, _ string) ([]Diagnostic, error) {
+ return nil, nil
+}
+
+func TestDiagnostic_JSONRoundtrip(t *testing.T) {
+ d := Diagnostic{
+ ID: "dep-abc", Phase: "pre_deploy", Cause: "exit 1",
+ At: time.Now().UTC().Truncate(time.Second), Detail: "line1\nline2",
+ }
+ // simple JSON marshal/unmarshal sanity
+ // (will fail initially if fields aren't exported with json tags)
+ _ = d
+}
+```
+
+**Step 3: Run tests**
+
+Run: `go test ./interfaces/... -run TestDiagnostic`
+Expected: PASS
+
+**Step 4: Commit**
+
+```bash
+git add interfaces/iac_resource_driver.go interfaces/iac_resource_driver_test.go
+git commit -m "interfaces: add Detail field + compile-time Troubleshooter check"
+```
+
+---
+
+### Task 2: gRPC dispatch for Troubleshoot
+
+**Files:**
+- Modify: `cmd/wfctl/deploy_providers.go:633-700` (existing `remoteResourceDriver`)
+- Test: `cmd/wfctl/remote_resource_driver_test.go` (create)
+
+**Background:** wfctl's `remoteResourceDriver` wraps gRPC calls via `d.invoker.InvokeService("ResourceDriver.", …)`. Add a `Troubleshoot` method that sends arguments and returns `[]Diagnostic`. When the plugin returns `codes.Unimplemented` (or similar), swallow and return `(nil, nil)` so callers see "no diagnostics" rather than an error.
+
+**Step 1: Write the failing test**
+
+Create `cmd/wfctl/remote_resource_driver_test.go`:
+
+```go
+package main
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/GoCodeAlone/workflow/interfaces"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
+)
+
+type stubInvoker struct {
+ svcCalled string
+ argsSeen map[string]any
+ resp map[string]any
+ err error
+}
+
+func (s *stubInvoker) InvokeService(svc string, args map[string]any) (map[string]any, error) {
+ s.svcCalled = svc
+ s.argsSeen = args
+ return s.resp, s.err
+}
+
+func TestRemoteResourceDriver_Troubleshoot_Success(t *testing.T) {
+ inv := &stubInvoker{
+ resp: map[string]any{
+ "diagnostics": []any{
+ map[string]any{
+ "id": "dep-1", "phase": "pre_deploy",
+ "cause": "exit 1", "at": "2026-04-24T00:00:00Z",
+ "detail": "log tail",
+ },
+ },
+ },
+ }
+ d := &remoteResourceDriver{invoker: inv}
+ diags, err := d.Troubleshoot(context.Background(), interfaces.ResourceRef{Name: "x"}, "boom")
+ if err != nil {
+ t.Fatalf("unexpected: %v", err)
+ }
+ if len(diags) != 1 || diags[0].Cause != "exit 1" {
+ t.Fatalf("unexpected diags: %+v", diags)
+ }
+ if inv.svcCalled != "ResourceDriver.Troubleshoot" {
+ t.Errorf("wrong svc: %s", inv.svcCalled)
+ }
+}
+
+func TestRemoteResourceDriver_Troubleshoot_UnimplementedSilent(t *testing.T) {
+ inv := &stubInvoker{err: status.Error(codes.Unimplemented, "method not implemented")}
+ d := &remoteResourceDriver{invoker: inv}
+ diags, err := d.Troubleshoot(context.Background(), interfaces.ResourceRef{Name: "x"}, "boom")
+ if err != nil {
+ t.Fatalf("Unimplemented should not surface: %v", err)
+ }
+ if diags != nil {
+ t.Fatalf("expected nil diags, got %+v", diags)
+ }
+}
+
+func TestRemoteResourceDriver_Troubleshoot_OtherErrorSurfaces(t *testing.T) {
+ inv := &stubInvoker{err: errors.New("network oops")}
+ d := &remoteResourceDriver{invoker: inv}
+ _, err := d.Troubleshoot(context.Background(), interfaces.ResourceRef{Name: "x"}, "boom")
+ if err == nil {
+ t.Fatal("expected error to surface")
+ }
+}
+```
+
+**Step 2: Run the test — should fail to compile**
+
+Run: `go test ./cmd/wfctl/... -run TestRemoteResourceDriver_Troubleshoot -v`
+Expected: compile error (Troubleshoot method doesn't exist on remoteResourceDriver).
+
+**Step 3: Implement the method on `remoteResourceDriver`**
+
+Add to `cmd/wfctl/deploy_providers.go` near the other `remoteResourceDriver` methods (around line 680):
+
+```go
+// Troubleshoot calls the plugin's optional Troubleshooter.Troubleshoot.
+// Returns (nil, nil) silently when the plugin returns Unimplemented so
+// the caller doesn't need to probe for capability — absence is a valid answer.
+func (d *remoteResourceDriver) Troubleshoot(ctx context.Context, ref interfaces.ResourceRef, failureMsg string) ([]interfaces.Diagnostic, error) {
+ res, err := d.invoker.InvokeService("ResourceDriver.Troubleshoot", map[string]any{
+ "ref": ref,
+ "failure_msg": failureMsg,
+ })
+ if err != nil {
+ if st, ok := status.FromError(err); ok && st.Code() == codes.Unimplemented {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("resource driver Troubleshoot: %w", err)
+ }
+ raw, _ := res["diagnostics"].([]any)
+ out := make([]interfaces.Diagnostic, 0, len(raw))
+ for _, r := range raw {
+ m, _ := r.(map[string]any)
+ d := interfaces.Diagnostic{
+ ID: stringVal(m, "id"),
+ Phase: stringVal(m, "phase"),
+ Cause: stringVal(m, "cause"),
+ Detail: stringVal(m, "detail"),
+ }
+ if s := stringVal(m, "at"); s != "" {
+ if t, perr := time.Parse(time.RFC3339, s); perr == nil {
+ d.At = t
+ }
+ }
+ out = append(out, d)
+ }
+ return out, nil
+}
+
+// stringVal returns a string field or "" if missing/wrong type.
+func stringVal(m map[string]any, k string) string {
+ if v, ok := m[k].(string); ok {
+ return v
+ }
+ return ""
+}
+```
+
+Add imports if missing: `"google.golang.org/grpc/codes"`, `"google.golang.org/grpc/status"`, `"time"`.
+
+**Step 4: Run the tests**
+
+Run: `go test ./cmd/wfctl/... -run TestRemoteResourceDriver_Troubleshoot -v`
+Expected: 3/3 PASS.
+
+**Step 5: Commit**
+
+```bash
+git add cmd/wfctl/deploy_providers.go cmd/wfctl/remote_resource_driver_test.go
+git commit -m "wfctl: Troubleshoot gRPC dispatch with Unimplemented fallback"
+```
+
+---
+
+### Task 3: CIGroupEmitter — provider detection + group markers
+
+**Files:**
+- Create: `cmd/wfctl/ci_output.go`
+- Test: `cmd/wfctl/ci_output_test.go`
+
+**Step 1: Write the failing test**
+
+Create `cmd/wfctl/ci_output_test.go`:
+
+```go
+package main
+
+import (
+ "bytes"
+ "os"
+ "strings"
+ "testing"
+)
+
+func TestDetectCIProvider_GitHub(t *testing.T) {
+ t.Setenv("GITHUB_ACTIONS", "true")
+ t.Setenv("GITLAB_CI", "")
+ t.Setenv("JENKINS_HOME", "")
+ t.Setenv("CIRCLECI", "")
+ e := detectCIProvider()
+ if _, ok := e.(githubEmitter); !ok {
+ t.Fatalf("expected githubEmitter, got %T", e)
+ }
+}
+
+func TestDetectCIProvider_GitLab(t *testing.T) {
+ t.Setenv("GITHUB_ACTIONS", "")
+ t.Setenv("GITLAB_CI", "true")
+ e := detectCIProvider()
+ if _, ok := e.(gitlabEmitter); !ok {
+ t.Fatalf("expected gitlabEmitter, got %T", e)
+ }
+}
+
+func TestDetectCIProvider_Default(t *testing.T) {
+ t.Setenv("GITHUB_ACTIONS", "")
+ t.Setenv("GITLAB_CI", "")
+ t.Setenv("JENKINS_HOME", "")
+ t.Setenv("CIRCLECI", "")
+ e := detectCIProvider()
+ if _, ok := e.(plainEmitter); !ok {
+ t.Fatalf("expected plainEmitter, got %T", e)
+ }
+}
+
+func TestGithubEmitter_GroupMarkers(t *testing.T) {
+ var buf bytes.Buffer
+ e := githubEmitter{}
+ e.GroupStart(&buf, "Troubleshoot: bmw-staging")
+ buf.WriteString("hello\n")
+ e.GroupEnd(&buf)
+ out := buf.String()
+ if !strings.Contains(out, "::group::Troubleshoot: bmw-staging\n") {
+ t.Errorf("missing ::group:: marker: %q", out)
+ }
+ if !strings.Contains(out, "::endgroup::\n") {
+ t.Errorf("missing ::endgroup:: marker: %q", out)
+ }
+}
+
+func TestGitlabEmitter_GroupMarkers(t *testing.T) {
+ var buf bytes.Buffer
+ e := gitlabEmitter{}
+ e.GroupStart(&buf, "my-section")
+ e.GroupEnd(&buf)
+ out := buf.String()
+ if !strings.Contains(out, "section_start") {
+ t.Errorf("missing section_start: %q", out)
+ }
+ if !strings.Contains(out, "section_end") {
+ t.Errorf("missing section_end: %q", out)
+ }
+}
+
+func TestPlainEmitter_UsesDashSeparators(t *testing.T) {
+ var buf bytes.Buffer
+ e := plainEmitter{}
+ e.GroupStart(&buf, "section")
+ e.GroupEnd(&buf)
+ out := buf.String()
+ if !strings.Contains(out, "--- section ---") {
+ t.Errorf("expected --- section --- header, got %q", out)
+ }
+ _ = os.Stdout
+}
+```
+
+**Step 2: Run — expected compile error**
+
+Run: `go test ./cmd/wfctl/... -run TestDetectCIProvider -v`
+Expected: undefined `detectCIProvider`, `githubEmitter`, etc.
+
+**Step 3: Implement `ci_output.go`**
+
+Create `cmd/wfctl/ci_output.go`:
+
+```go
+package main
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "time"
+)
+
+// CIGroupEmitter wraps output with CI-provider-specific group markers so
+// long outputs (like diagnostics) render as collapsible sections in the UI.
+type CIGroupEmitter interface {
+ GroupStart(w io.Writer, name string)
+ GroupEnd(w io.Writer)
+ // SummaryPath returns the path to append Markdown summary content, or
+ // "" if this CI provider has no step-summary concept.
+ SummaryPath() string
+}
+
+// detectCIProvider inspects env vars and returns the appropriate emitter.
+func detectCIProvider() CIGroupEmitter {
+ switch {
+ case os.Getenv("GITHUB_ACTIONS") == "true":
+ return githubEmitter{summaryPath: os.Getenv("GITHUB_STEP_SUMMARY")}
+ case os.Getenv("GITLAB_CI") == "true":
+ return gitlabEmitter{}
+ case os.Getenv("JENKINS_HOME") != "":
+ return jenkinsEmitter{}
+ case os.Getenv("CIRCLECI") == "true":
+ return circleCIEmitter{}
+ default:
+ return plainEmitter{}
+ }
+}
+
+// --- GitHub Actions ---
+
+type githubEmitter struct{ summaryPath string }
+
+func (g githubEmitter) GroupStart(w io.Writer, name string) {
+ fmt.Fprintf(w, "::group::%s\n", name)
+}
+func (g githubEmitter) GroupEnd(w io.Writer) { fmt.Fprintln(w, "::endgroup::") }
+func (g githubEmitter) SummaryPath() string { return g.summaryPath }
+
+// --- GitLab CI ---
+
+type gitlabEmitter struct{}
+
+func (g gitlabEmitter) GroupStart(w io.Writer, name string) {
+ id := fmt.Sprintf("wfctl_%d", time.Now().UnixNano())
+ fmt.Fprintf(w, "\x1b[0Ksection_start:%d:%s\r\x1b[0K%s\n", time.Now().Unix(), id, name)
+}
+func (g gitlabEmitter) GroupEnd(w io.Writer) {
+ id := fmt.Sprintf("wfctl_%d", time.Now().UnixNano())
+ fmt.Fprintf(w, "\x1b[0Ksection_end:%d:%s\r\x1b[0K\n", time.Now().Unix(), id)
+}
+func (g gitlabEmitter) SummaryPath() string { return "" }
+
+// --- Jenkins ---
+
+type jenkinsEmitter struct{}
+
+func (j jenkinsEmitter) GroupStart(w io.Writer, name string) { fmt.Fprintf(w, "\n--- %s ---\n", name) }
+func (j jenkinsEmitter) GroupEnd(w io.Writer) { fmt.Fprintln(w, "--- end ---") }
+func (j jenkinsEmitter) SummaryPath() string { return "" }
+
+// --- CircleCI ---
+
+type circleCIEmitter struct{}
+
+func (c circleCIEmitter) GroupStart(w io.Writer, name string) { fmt.Fprintf(w, "\n--- %s ---\n", name) }
+func (c circleCIEmitter) GroupEnd(w io.Writer) { fmt.Fprintln(w, "--- end ---") }
+func (c circleCIEmitter) SummaryPath() string { return "" }
+
+// --- Plain (default) ---
+
+type plainEmitter struct{}
+
+func (p plainEmitter) GroupStart(w io.Writer, name string) { fmt.Fprintf(w, "\n--- %s ---\n", name) }
+func (p plainEmitter) GroupEnd(w io.Writer) { fmt.Fprintln(w, "--- end ---") }
+func (p plainEmitter) SummaryPath() string { return "" }
+```
+
+**Step 4: Run tests**
+
+Run: `go test ./cmd/wfctl/... -run "TestDetectCIProvider|TestGithubEmitter|TestGitlabEmitter|TestPlainEmitter" -v`
+Expected: 5/5 PASS.
+
+**Step 5: Commit**
+
+```bash
+git add cmd/wfctl/ci_output.go cmd/wfctl/ci_output_test.go
+git commit -m "wfctl: CIGroupEmitter with provider detection (GHA/GitLab/Jenkins/CircleCI)"
+```
+
+---
+
+### Task 4: Step-summary Markdown writer
+
+**Files:**
+- Create: `cmd/wfctl/ci_output_summary.go`
+- Test: `cmd/wfctl/ci_output_summary_test.go`
+- Create: `cmd/wfctl/testdata/summary_failure.golden.md`
+- Create: `cmd/wfctl/testdata/summary_success.golden.md`
+
+**Step 1: Define the summary rendering contract**
+
+Create `cmd/wfctl/ci_output_summary.go`:
+
+```go
+package main
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/GoCodeAlone/workflow/interfaces"
+)
+
+// SummaryInput is the data bundled into a step-summary Markdown block.
+type SummaryInput struct {
+ Operation string // "deploy" | "apply" | "destroy"
+ Env string // e.g. "staging"
+ Resource string // resource display name
+ Outcome string // "SUCCESS" | "FAILED"
+ ConsoleURL string // direct link to provider dashboard
+ Diagnostics []interfaces.Diagnostic
+ Phases []PhaseTiming
+ RootCause string
+}
+
+type PhaseTiming struct {
+ Name string
+ Status string
+ Duration time.Duration
+}
+
+// WriteStepSummary appends Markdown to the CI provider's summary destination.
+// No-ops when the provider has no summary destination (all non-GHA for now).
+func WriteStepSummary(emitter CIGroupEmitter, in SummaryInput) error {
+ path := emitter.SummaryPath()
+ if path == "" {
+ return nil
+ }
+ f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
+ if err != nil {
+ return fmt.Errorf("open summary: %w", err)
+ }
+ defer f.Close()
+ return renderSummary(f, in)
+}
+
+func renderSummary(w io.Writer, in SummaryInput) error {
+ var b strings.Builder
+ fmt.Fprintf(&b, "## wfctl: %s %s — %s\n\n", in.Operation, in.Env, in.Outcome)
+ if in.Resource != "" {
+ fmt.Fprintf(&b, "**Resource:** %s\n", in.Resource)
+ }
+ if in.RootCause != "" {
+ fmt.Fprintf(&b, "**Root cause:** `%s`\n", in.RootCause)
+ }
+ if in.ConsoleURL != "" {
+ fmt.Fprintf(&b, "**Console:** %s\n", in.ConsoleURL)
+ }
+ if len(in.Phases) > 0 {
+ b.WriteString("\n### Phase timings\n\n| Phase | Status | Duration |\n|---|---|---|\n")
+ for _, p := range in.Phases {
+ fmt.Fprintf(&b, "| %s | %s | %s |\n", p.Name, p.Status, p.Duration.Round(time.Second))
+ }
+ }
+ if len(in.Diagnostics) > 0 {
+ b.WriteString("\n### Diagnostics\n\n")
+ for _, d := range in.Diagnostics {
+ fmt.Fprintf(&b, "- **[%s]** `%s` — %s\n", d.Phase, d.ID, d.Cause)
+ if d.Detail != "" {
+ fmt.Fprintf(&b, " log tail
\n\n ```\n %s\n ```\n\n \n", strings.ReplaceAll(d.Detail, "\n", "\n "))
+ }
+ }
+ }
+ _, err := io.WriteString(w, b.String())
+ return err
+}
+```
+
+**Step 2: Write the golden-file test**
+
+Create `cmd/wfctl/ci_output_summary_test.go`:
+
+```go
+package main
+
+import (
+ "bytes"
+ "flag"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/GoCodeAlone/workflow/interfaces"
+)
+
+var updateGolden = flag.Bool("update-golden", false, "update golden files")
+
+func TestRenderSummary_Failure_Golden(t *testing.T) {
+ in := SummaryInput{
+ Operation: "deploy",
+ Env: "staging",
+ Resource: "bmw-staging",
+ Outcome: "FAILED",
+ ConsoleURL: "https://cloud.digitalocean.com/apps/abc",
+ RootCause: "workflow-migrate up: first .: file does not exist",
+ Phases: []PhaseTiming{
+ {Name: "build", Status: "SUCCESS", Duration: 134 * time.Second},
+ {Name: "pre_deploy", Status: "ERROR", Duration: 3 * time.Second},
+ },
+ Diagnostics: []interfaces.Diagnostic{
+ {ID: "dep-123", Phase: "pre_deploy", Cause: "exit status 1",
+ At: mustTime("2026-04-24T17:42:45Z"),
+ Detail: "workflow-migrate up: first .: file does not exist\nError: exit status 1"},
+ },
+ }
+ var got bytes.Buffer
+ if err := renderSummary(&got, in); err != nil {
+ t.Fatal(err)
+ }
+ compareGolden(t, "summary_failure.golden.md", got.String())
+}
+
+func TestRenderSummary_Success_Golden(t *testing.T) {
+ in := SummaryInput{
+ Operation: "deploy", Env: "staging", Resource: "bmw-staging",
+ Outcome: "SUCCESS", ConsoleURL: "https://cloud.digitalocean.com/apps/abc",
+ Phases: []PhaseTiming{
+ {Name: "build", Status: "SUCCESS", Duration: 134 * time.Second},
+ {Name: "pre_deploy", Status: "SUCCESS", Duration: 12 * time.Second},
+ {Name: "deploy", Status: "SUCCESS", Duration: 45 * time.Second},
+ },
+ }
+ var got bytes.Buffer
+ if err := renderSummary(&got, in); err != nil {
+ t.Fatal(err)
+ }
+ compareGolden(t, "summary_success.golden.md", got.String())
+}
+
+func TestWriteStepSummary_NoPathNoop(t *testing.T) {
+ e := plainEmitter{}
+ if err := WriteStepSummary(e, SummaryInput{}); err != nil {
+ t.Fatalf("plain emitter should noop, got err: %v", err)
+ }
+}
+
+func TestWriteStepSummary_GHA_AppendsToFile(t *testing.T) {
+ tmp := t.TempDir()
+ path := filepath.Join(tmp, "summary.md")
+ e := githubEmitter{summaryPath: path}
+ if err := WriteStepSummary(e, SummaryInput{
+ Operation: "apply", Env: "staging", Outcome: "SUCCESS",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Contains(data, []byte("## wfctl: apply staging — SUCCESS")) {
+ t.Errorf("summary missing header: %q", data)
+ }
+}
+
+func mustTime(s string) time.Time {
+ t, err := time.Parse(time.RFC3339, s)
+ if err != nil { panic(err) }
+ return t
+}
+
+func compareGolden(t *testing.T, name, got string) {
+ t.Helper()
+ path := filepath.Join("testdata", name)
+ if *updateGolden {
+ if err := os.MkdirAll("testdata", 0o755); err != nil { t.Fatal(err) }
+ if err := os.WriteFile(path, []byte(got), 0o644); err != nil { t.Fatal(err) }
+ return
+ }
+ want, err := os.ReadFile(path)
+ if err != nil { t.Fatalf("read golden: %v (run with -update-golden)", err) }
+ if got != string(want) {
+ t.Errorf("golden mismatch in %s\ngot:\n%s\nwant:\n%s", name, got, want)
+ }
+}
+```
+
+**Step 3: Generate golden files**
+
+Run: `go test ./cmd/wfctl/... -run TestRenderSummary -update-golden`
+Expected: creates `cmd/wfctl/testdata/summary_failure.golden.md` and `summary_success.golden.md`.
+
+**Step 4: Re-run tests without `-update-golden`**
+
+Run: `go test ./cmd/wfctl/... -run "TestRenderSummary|TestWriteStepSummary" -v`
+Expected: 4/4 PASS.
+
+**Step 5: Spot-check golden files**
+
+Run: `cat cmd/wfctl/testdata/summary_failure.golden.md`
+Expected: well-formed Markdown with `## wfctl: deploy staging — FAILED`, `**Resource:**`, `**Root cause:**`, phase table, diagnostic entry with collapsible log tail.
+
+**Step 6: Commit**
+
+```bash
+git add cmd/wfctl/ci_output_summary.go cmd/wfctl/ci_output_summary_test.go cmd/wfctl/testdata/
+git commit -m "wfctl: step-summary Markdown writer with golden tests"
+```
+
+---
+
+### Task 5: Wire Troubleshoot into `ci run --phase deploy` failure path
+
+**Files:**
+- Modify: `cmd/wfctl/deploy_providers.go:1028-1132` (HealthCheck + pollUntilHealthy)
+- Test: `cmd/wfctl/deploy_providers_troubleshoot_test.go` (create)
+
+**Background:** `pluginDeployProvider.HealthCheck` (line 1028) calls `pollUntilHealthy` (line 1053) which returns a terminal error on timeout (line 1132). Wire Troubleshoot invocation there: on any terminal error, call `driver.Troubleshoot`, render with `CIGroupEmitter`, and write step summary.
+
+**Step 1: Write the failing test**
+
+Create `cmd/wfctl/deploy_providers_troubleshoot_test.go`:
+
+```go
+package main
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/GoCodeAlone/workflow/interfaces"
+)
+
+type fakeTroubleshootingDriver struct {
+ interfaces.ResourceDriver // embed for forward-compatibility; nil is fine for tests that don't call other methods
+ diags []interfaces.Diagnostic
+ err error
+ calls int
+}
+
+func (f *fakeTroubleshootingDriver) Troubleshoot(_ context.Context, _ interfaces.ResourceRef, _ string) ([]interfaces.Diagnostic, error) {
+ f.calls++
+ return f.diags, f.err
+}
+
+func TestEmitDiagnostics_WritesGroupBlock(t *testing.T) {
+ t.Setenv("GITHUB_ACTIONS", "true")
+ var buf bytes.Buffer
+ diags := []interfaces.Diagnostic{
+ {ID: "dep-1", Phase: "pre_deploy", Cause: "exit 1",
+ At: mustTime("2026-04-24T00:00:00Z"),
+ Detail: "migration failed"},
+ }
+ emitDiagnostics(&buf, "bmw-staging", diags, detectCIProvider())
+ out := buf.String()
+ if !strings.Contains(out, "::group::Troubleshoot: bmw-staging") {
+ t.Errorf("missing group marker: %q", out)
+ }
+ if !strings.Contains(out, "[pre_deploy]") || !strings.Contains(out, "exit 1") {
+ t.Errorf("missing diagnostic body: %q", out)
+ }
+ if !strings.Contains(out, "::endgroup::") {
+ t.Errorf("missing endgroup: %q", out)
+ }
+}
+
+func TestEmitDiagnostics_EmptyIsNoop(t *testing.T) {
+ var buf bytes.Buffer
+ emitDiagnostics(&buf, "x", nil, plainEmitter{})
+ if buf.Len() != 0 {
+ t.Errorf("expected no output for empty diags, got %q", buf.String())
+ }
+}
+
+func TestTroubleshootAfterFailure_Timeout(t *testing.T) {
+ f := &fakeTroubleshootingDriver{
+ diags: []interfaces.Diagnostic{{ID: "d", Phase: "run", Cause: "ouch", At: mustTime("2026-04-24T00:00:00Z")}},
+ }
+ var buf bytes.Buffer
+ origErr := errors.New("plugin health check \"bmw-staging\": timed out waiting for healthy")
+ troubleshootAfterFailure(context.Background(), &buf, f, interfaces.ResourceRef{Name: "bmw-staging"}, origErr, 30*time.Second, plainEmitter{})
+ if f.calls != 1 {
+ t.Errorf("Troubleshoot not called: calls=%d", f.calls)
+ }
+ if !strings.Contains(buf.String(), "ouch") {
+ t.Errorf("missing Cause in output: %q", buf.String())
+ }
+}
+
+func TestTroubleshootAfterFailure_NonTroubleshooterSkips(t *testing.T) {
+ var buf bytes.Buffer
+ type plainDriver struct{ interfaces.ResourceDriver }
+ troubleshootAfterFailure(context.Background(), &buf, &plainDriver{}, interfaces.ResourceRef{}, errors.New("x"), 30*time.Second, plainEmitter{})
+ if buf.Len() != 0 {
+ t.Errorf("non-troubleshooter should not produce output: %q", buf.String())
+ }
+}
+```
+
+**Step 2: Run — expect compile error for missing helpers**
+
+Run: `go test ./cmd/wfctl/... -run "TestEmitDiagnostics|TestTroubleshootAfterFailure" -v`
+Expected: undefined `emitDiagnostics`, `troubleshootAfterFailure`.
+
+**Step 3: Implement helpers in `cmd/wfctl/deploy_providers.go`**
+
+Add near the other helpers (around line 1140):
+
+```go
+// emitDiagnostics renders diagnostics into a CI group block on w.
+// No-op when diags is empty.
+func emitDiagnostics(w io.Writer, resource string, diags []interfaces.Diagnostic, em CIGroupEmitter) {
+ if len(diags) == 0 {
+ return
+ }
+ em.GroupStart(w, fmt.Sprintf("Troubleshoot: %s", resource))
+ for _, d := range diags {
+ fmt.Fprintf(w, " [%s] %s — %s (at %s)\n", d.Phase, d.ID, d.Cause, d.At.Format(time.RFC3339))
+ if d.Detail != "" {
+ for _, line := range strings.Split(strings.TrimRight(d.Detail, "\n"), "\n") {
+ fmt.Fprintf(w, " %s\n", line)
+ }
+ }
+ }
+ em.GroupEnd(w)
+}
+
+// troubleshootAfterFailure probes driver for Troubleshooter, calls it with a bounded
+// timeout, and renders diagnostics via the provided emitter. All errors are swallowed
+// (observability is additive; never masks original failure).
+func troubleshootAfterFailure(ctx context.Context, w io.Writer, driver interface{}, ref interfaces.ResourceRef, origErr error, timeout time.Duration, em CIGroupEmitter) {
+ ts, ok := driver.(interfaces.Troubleshooter)
+ if !ok {
+ return
+ }
+ tsCtx, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+ diags, err := ts.Troubleshoot(tsCtx, ref, origErr.Error())
+ if err != nil {
+ log.Printf("troubleshoot: %v (ignored)", err)
+ return
+ }
+ emitDiagnostics(w, ref.Name, diags, em)
+}
+```
+
+Add imports if missing: `"io"`, `"strings"`.
+
+**Step 4: Run helper tests**
+
+Run: `go test ./cmd/wfctl/... -run "TestEmitDiagnostics|TestTroubleshootAfterFailure" -v`
+Expected: 4/4 PASS.
+
+**Step 5: Wire into `pollUntilHealthy` terminal failure path**
+
+At `cmd/wfctl/deploy_providers.go:1132` (where `baseErr` is built on timeout), before returning the error, call `troubleshootAfterFailure`. Locate the `return fmt.Errorf(...)` line at ~1132 and change:
+
+```go
+// before
+return errors.New(baseErr + " (last known state: " + result.Message + ")")
+```
+
+to:
+
+```go
+// after
+em := detectCIProvider()
+troubleshootAfterFailure(ctx, os.Stderr, driver, ref, errors.New(baseErr), 30*time.Second, em)
+return errors.New(baseErr + " (last known state: " + result.Message + ")")
+```
+
+Similarly at the other `return fmt.Errorf("plugin health check %q: %w", name, wrapped)` at ~1089 (non-transient driver error) wire in the same call before the return.
+
+**Step 6: Build and run existing deploy tests to verify no regression**
+
+Run: `go build ./... && go test ./cmd/wfctl/... -run "TestPluginDeploy|TestPollUntilHealthy" -v`
+Expected: existing tests pass; new troubleshoot wiring compiles.
+
+**Step 7: Commit**
+
+```bash
+git add cmd/wfctl/deploy_providers.go cmd/wfctl/deploy_providers_troubleshoot_test.go
+git commit -m "wfctl: call Troubleshoot after deploy health-check failure"
+```
+
+---
+
+### Task 6: Wire Troubleshoot into `infra apply` failure path
+
+**Files:**
+- Modify: `cmd/wfctl/infra_apply.go` (find the driver.Apply/HealthCheck error return paths)
+- Test: `cmd/wfctl/infra_apply_troubleshoot_test.go` (create)
+
+**Step 1: Locate call sites**
+
+Run: `grep -n "driver.Apply\|driver.HealthCheck\|return.*err" cmd/wfctl/infra_apply.go | head -20`
+Identify the error-return paths where the driver's Apply or HealthCheck failed.
+
+**Step 2: Add the same `troubleshootAfterFailure` call before those returns**
+
+Pattern (apply this at each failure site):
+
+```go
+if err != nil {
+ em := detectCIProvider()
+ troubleshootAfterFailure(ctx, os.Stderr, driver, ref, err, 30*time.Second, em)
+ return fmt.Errorf("apply %s: %w", ref.Name, err)
+}
+```
+
+The exact placement depends on the file's structure — do NOT invent new error paths; only wrap existing ones.
+
+**Step 3: Write an integration-level test**
+
+Create `cmd/wfctl/infra_apply_troubleshoot_test.go` with a test that:
+- Builds a fake `driver` implementing both `ResourceDriver` and `Troubleshooter`
+- Calls `infra apply`'s top-level exec with a plan that has one action failing
+- Asserts stderr contains the `Troubleshoot:` group block with the diagnostic
+- Asserts the outer error is unchanged (original failure message preserved)
+
+Skeleton:
+
+```go
+func TestInfraApply_EmitsDiagnosticsOnFailure(t *testing.T) {
+ t.Setenv("GITHUB_ACTIONS", "true")
+ // set up fake driver, fake state store, fake resolvers
+ // call the smallest applyPlan()-style helper that wraps driver.Apply error
+ // capture stderr via a pipe
+ // assert "Troubleshoot: bmw-staging" and the Cause appear
+}
+```
+
+(Plan writer: the executing agent should read `infra_apply.go` + surrounding test scaffolding to pick the right helper to exercise. If the existing test harness doesn't support injecting a fake driver, note it as a blocker and escalate to team-lead; do NOT invent test infrastructure for this task.)
+
+**Step 4: Run test**
+
+Run: `go test ./cmd/wfctl/... -run TestInfraApply_EmitsDiagnosticsOnFailure -v`
+Expected: PASS.
+
+**Step 5: Commit**
+
+```bash
+git add cmd/wfctl/infra_apply.go cmd/wfctl/infra_apply_troubleshoot_test.go
+git commit -m "wfctl: call Troubleshoot after infra apply failure"
+```
+
+---
+
+### Task 7: End-to-end test — deploy failure surfaces diagnostics
+
+**Files:**
+- Create: `cmd/wfctl/e2e_deploy_troubleshoot_test.go`
+
+This is a broader table-driven test to lock in behavior.
+
+**Step 1: Write the test**
+
+```go
+package main
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/GoCodeAlone/workflow/interfaces"
+)
+
+func TestE2EDeployFailure_EmitsFullTroubleshootBlock(t *testing.T) {
+ t.Setenv("GITHUB_ACTIONS", "true")
+ summary := t.TempDir() + "/summary.md"
+ t.Setenv("GITHUB_STEP_SUMMARY", summary)
+
+ diags := []interfaces.Diagnostic{
+ {ID: "dep-1", Phase: "pre_deploy", Cause: "migration failed",
+ At: mustTime("2026-04-24T17:42:45Z"),
+ Detail: "exit status 1"},
+ {ID: "dep-1", Phase: "build", Cause: "",
+ At: mustTime("2026-04-24T17:40:00Z"), Detail: ""},
+ }
+ f := &fakeTroubleshootingDriver{diags: diags}
+ var stderr bytes.Buffer
+ em := detectCIProvider()
+ troubleshootAfterFailure(context.Background(), &stderr, f, interfaces.ResourceRef{Name: "bmw-staging"},
+ errors.New("timed out"), 30*time.Second, em)
+ if f.calls != 1 {
+ t.Fatal("Troubleshoot not called")
+ }
+ out := stderr.String()
+ if !strings.Contains(out, "::group::Troubleshoot: bmw-staging") { t.Error("missing group") }
+ if !strings.Contains(out, "[pre_deploy]") { t.Error("missing phase marker") }
+ if !strings.Contains(out, "migration failed") { t.Error("missing cause") }
+ if !strings.Contains(out, "exit status 1") { t.Error("missing detail") }
+}
+
+func TestE2ENoTroubleshooter_NoCrash(t *testing.T) {
+ var stderr bytes.Buffer
+ type plainDriver struct{ interfaces.ResourceDriver }
+ troubleshootAfterFailure(context.Background(), &stderr, &plainDriver{}, interfaces.ResourceRef{},
+ errors.New("x"), 30*time.Second, plainEmitter{})
+ if stderr.Len() != 0 { t.Errorf("unexpected output: %q", stderr.String()) }
+}
+```
+
+**Step 2: Run**
+
+Run: `go test ./cmd/wfctl/... -run TestE2EDeploy -v`
+Expected: 2/2 PASS.
+
+**Step 3: Commit**
+
+```bash
+git add cmd/wfctl/e2e_deploy_troubleshoot_test.go
+git commit -m "wfctl: e2e tests for Troubleshoot wiring"
+```
+
+---
+
+### Task 8: CHANGELOG.md entry
+
+**Files:**
+- Modify: `CHANGELOG.md` (at repo root)
+
+**Step 1: Add v0.18.10 section**
+
+Prepend to CHANGELOG.md (or create if missing):
+
+```markdown
+## v0.18.10 — 2026-04-24
+
+### Added
+- `interfaces.Troubleshooter` optional interface on `ResourceDriver`. Drivers implementing it
+ return structured `[]Diagnostic` (phase, cause, timestamp, log-tail detail) that wfctl renders
+ in CI output on deploy/apply failure. No changes required for drivers that don't implement it.
+- `cmd/wfctl/ci_output.go`: CI-provider detection (GitHub Actions, GitLab CI, Jenkins, CircleCI)
+ with grouped output via provider-native markers (`::group::`, `section_start`, dashed separators).
+- `cmd/wfctl/ci_output_summary.go`: Markdown step-summary writer for
+ `$GITHUB_STEP_SUMMARY` (and equivalents) with resource, root cause, phase timings,
+ and collapsible per-diagnostic log tails.
+
+### Changed
+- `wfctl ci run --phase deploy` and `wfctl infra apply` automatically invoke
+ `Troubleshooter.Troubleshoot` on any terminal failure (health-check timeout or driver
+ error), render diagnostics, and write the step-summary. Original exit codes and error
+ messages are preserved (observability is additive).
+
+### Not yet
+- Generic `IaCProvider.StreamLogs` for real-time log display during builds — deferred to
+ v0.19.0 alongside plugin-manifest split (#42) and provider-agnostic CI summary (#63).
+- AWS, GCP, Azure, tofu Troubleshoot implementations — DO only in v0.7.8; others no-op.
+```
+
+**Step 2: Commit**
+
+```bash
+git add CHANGELOG.md
+git commit -m "docs: CHANGELOG v0.18.10"
+```
+
+---
+
+### Task 9: Open PR on workflow repo
+
+**Files:** none (git operations only).
+
+**Step 1: DM code-reviewer + spec-reviewer for pre-push review**
+
+Message template (send via team SendMessage):
+> PR #?? draft on workflow feat/v0.18.10-observability — v0.18.10 observability. X commits: . Design doc at docs/plans/2026-04-24-wfctl-deploy-log-observability-design.md. Standing by for approval before pushing.
+
+Wait for both reviewers to approve.
+
+**Step 2: Push branch**
+
+```bash
+git push -u origin feat/v0.18.10-observability
+```
+
+**Step 3: Open PR**
+
+```bash
+gh pr create --repo GoCodeAlone/workflow --title "feat: v0.18.10 — wfctl deploy-log observability (Troubleshooter interface)" --body "$(cat <<'EOF'
+## Summary
+- Adds optional `Troubleshooter` interface on `ResourceDriver` so drivers can explain deploy failures without the operator opening the provider console.
+- Wires wfctl to invoke `Troubleshoot` automatically after deploy health-check timeout or apply failure; renders diagnostics via CI-provider-agnostic group blocks (GHA, GitLab, Jenkins, CircleCI) and writes a Markdown summary to `$GITHUB_STEP_SUMMARY` (and equivalents).
+- Backwards compatible: drivers that don't implement Troubleshooter silently no-op via `codes.Unimplemented`.
+
+## Test plan
+- [ ] `go test ./...` passes (incl. new unit + golden-file tests)
+- [ ] `go vet ./...` clean
+- [ ] `gofmt -l` returns empty
+- [ ] After merge: DO plugin v0.7.8 implements `AppPlatformDriver.Troubleshoot`; BMW retries deploy and sees pre-deploy migration error surfaced in GHA output within 30s of timeout.
+
+Design: `docs/plans/2026-04-24-wfctl-deploy-log-observability-design.md`
+Follow-up: workflow-plugin-digitalocean v0.7.8 (separate PR, blocked by this tag)
+EOF
+)"
+```
+
+**Step 4: Add Copilot reviewer**
+
+```bash
+PR_NUM=$(gh pr view --repo GoCodeAlone/workflow --json number -q '.number')
+gh pr edit "$PR_NUM" --repo GoCodeAlone/workflow --add-reviewer copilot-pull-request-reviewer
+```
+
+**Step 5: Wait 15+ min for Copilot review, address comments**
+
+Follow standard team process — do NOT `@copilot` in comment bodies (memory: use `--add-reviewer copilot-pull-request-reviewer`).
+
+**Step 6: DM team-lead for merge approval**
+
+Message: "workflow PR #?? ready for merge approval — v0.18.10"
+
+---
+
+### Task 10: Tag v0.18.10 (team-lead action, post-merge)
+
+**Files:** none.
+
+**Step 1: After PR merges to main, team-lead:**
+
+```bash
+cd /Users/jon/workspace/workflow
+git checkout main && git pull
+git log --oneline -3 # verify merge SHA
+git tag -a v0.18.10 -m "v0.18.10: wfctl deploy-log observability (Troubleshooter interface)"
+git push origin v0.18.10
+```
+
+**Step 2: Verify release workflow runs**
+
+```bash
+gh run list --repo GoCodeAlone/workflow --workflow=Release --limit 1
+```
+Expected: run started, status=in_progress, title references v0.18.10.
+
+**Step 3: Wait for release assets to be published, verify installer works**
+
+```bash
+gh release view v0.18.10 --repo GoCodeAlone/workflow --json assets --jq '.assets | length'
+```
+Expected: multiple asset rows (platform-specific binaries).
+
+---
+
+## Phase 2 — workflow-plugin-digitalocean v0.7.8
+
+### Task 11: Implement `AppPlatformDriver.Troubleshoot`
+
+**Repo:** `/Users/jon/workspace/workflow-plugin-digitalocean`, branch `feat/v0.7.8-troubleshoot`.
+
+**Files:**
+- Modify: `internal/drivers/app_platform.go` (add method)
+- Create: `internal/drivers/app_platform_troubleshoot_test.go`
+
+**Step 1: Write the failing test**
+
+```go
+package drivers
+
+import (
+ "context"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/GoCodeAlone/workflow/interfaces"
+ "github.com/digitalocean/godo"
+)
+
+type fakeAppClient struct {
+ app *godo.App
+ deps []*godo.Deployment
+ logs map[string]string // key: deploymentID + "/" + phase
+ appErr error
+}
+
+func (f *fakeAppClient) Get(_ context.Context, id string) (*godo.App, *godo.Response, error) {
+ return f.app, &godo.Response{Response: &http.Response{StatusCode: 200}}, f.appErr
+}
+// implement other godo.AppsService methods as needed; delegate to inner where not stubbed
+
+func TestTroubleshoot_PreDeployFailure(t *testing.T) {
+ // ... setup fakeAppClient with a deployment whose PreDeployPhase=ERROR
+ // and logs["dep-1/pre_deploy"] = "exit status 1\nmigration failed"
+ // call driver.Troubleshoot and assert:
+ // - at least one Diagnostic with Phase="pre_deploy", Cause contains "exit status 1"
+ // - Detail contains the full log body
+ // - order: most-recent deployment first, then phase ordering
+}
+
+func TestTroubleshoot_BuildFailure(t *testing.T) { /* similar */ }
+func TestTroubleshoot_RunExitCode(t *testing.T) { /* similar */ }
+func TestTroubleshoot_MultiDeployment(t *testing.T) { /* assert ordering */ }
+func TestTroubleshoot_UnknownResourceType(t *testing.T) {
+ // When ref points at a non-app_platform resource, return (nil, nil)
+}
+```
+
+**Step 2: Implement Troubleshoot on `AppPlatformDriver`**
+
+In `internal/drivers/app_platform.go`, add near the other driver methods:
+
+```go
+// Troubleshoot implements interfaces.Troubleshooter. Returns diagnostics for
+// the app's most recent deployments (up to 3) with per-phase logs tailored for
+// operator-readable failure diagnosis.
+func (d *AppPlatformDriver) Troubleshoot(ctx context.Context, ref interfaces.ResourceRef, failureMsg string) ([]interfaces.Diagnostic, error) {
+ if ref.ProviderID == "" {
+ return nil, nil
+ }
+ app, _, err := d.client.Get(ctx, ref.ProviderID)
+ if err != nil {
+ return nil, fmt.Errorf("troubleshoot: get app: %w", err)
+ }
+ if app == nil {
+ return nil, nil
+ }
+ // Collect candidate deployments: InProgress > Pending > Active, plus recent history
+ candidates := pickTroubleshootDeployments(app)
+ if len(candidates) == 0 {
+ return nil, nil
+ }
+ var out []interfaces.Diagnostic
+ for _, dep := range candidates {
+ for _, phase := range []string{"pre_deploy", "build", "deploy", "run"} {
+ diag, err := d.buildDiagnosticFor(ctx, app.ID, dep, phase)
+ if err != nil {
+ continue // best-effort per phase
+ }
+ if diag != nil {
+ out = append(out, *diag)
+ }
+ }
+ }
+ return out, nil
+}
+
+// pickTroubleshootDeployments returns up to 3 deployments to examine, in
+// priority order: InProgress, Pending, Active, then recent historical ones.
+func pickTroubleshootDeployments(app *godo.App) []*godo.Deployment { /* impl */ }
+
+// buildDiagnosticFor fetches phase logs and synthesizes a Diagnostic.
+// Returns nil,nil if the phase had no activity for this deployment.
+func (d *AppPlatformDriver) buildDiagnosticFor(ctx context.Context, appID string, dep *godo.Deployment, phase string) (*interfaces.Diagnostic, error) {
+ // 1. determine this phase's status on this deployment (via dep.Phase, dep.Progress, etc.)
+ // 2. GET /v2/apps/{appID}/deployments/{dep.ID}/logs?type=
+ // 3. extract tail (last 100 lines or 4 KB, whichever is smaller)
+ // 4. extract Cause via extractCause(tail)
+ // 5. return Diagnostic
+}
+
+// extractCause scans a log tail for common error patterns and returns the
+// first matching line (best-effort).
+func extractCause(tail string) string {
+ patterns := []string{
+ "Error:", "error:", "exit status", "exit code", "failed to", "panic:", "fatal:",
+ }
+ for _, line := range strings.Split(tail, "\n") {
+ for _, p := range patterns {
+ if strings.Contains(line, p) {
+ return strings.TrimSpace(line)
+ }
+ }
+ }
+ // fallback: last non-empty line
+ lines := strings.Split(strings.TrimRight(tail, "\n"), "\n")
+ for i := len(lines) - 1; i >= 0; i-- {
+ if s := strings.TrimSpace(lines[i]); s != "" {
+ return s
+ }
+ }
+ return ""
+}
+```
+
+Add imports: `"strings"`, `"github.com/GoCodeAlone/workflow/interfaces"` (if not already).
+
+**Step 3: Run tests**
+
+Run: `go test ./internal/drivers/... -run TestTroubleshoot -v`
+Expected: all tests PASS.
+
+**Step 4: Commit**
+
+```bash
+git add internal/drivers/app_platform.go internal/drivers/app_platform_troubleshoot_test.go
+git commit -m "drivers: AppPlatformDriver.Troubleshoot implementation + tests"
+```
+
+---
+
+### Task 12: gRPC dispatch for Troubleshoot in DO plugin
+
+**Files:**
+- Modify: `internal/plugin/dispatcher.go` (or wherever `ResourceDriver.` dispatch lives)
+
+**Step 1: Locate existing dispatch pattern**
+
+Run: `grep -n "ResourceDriver.HealthCheck\|InvokeMethod\|case.*HealthCheck" internal/plugin/*.go | head -10`
+
+**Step 2: Add Troubleshoot dispatch case**
+
+In the dispatcher switch, add case mirror to HealthCheck:
+
+```go
+case "ResourceDriver.Troubleshoot":
+ ts, ok := driver.(interfaces.Troubleshooter)
+ if !ok {
+ return nil, status.Error(codes.Unimplemented, "driver does not implement Troubleshooter")
+ }
+ var ref interfaces.ResourceRef
+ if err := decodeArg(args, "ref", &ref); err != nil { return nil, err }
+ failureMsg, _ := args["failure_msg"].(string)
+ diags, err := ts.Troubleshoot(ctx, ref, failureMsg)
+ if err != nil { return nil, err }
+ return map[string]any{"diagnostics": diagnosticsToGrpcMap(diags)}, nil
+```
+
+Add `diagnosticsToGrpcMap` helper nearby converting `[]interfaces.Diagnostic` to `[]map[string]any` for gRPC marshaling.
+
+**Step 3: Unit test the dispatch path**
+
+```go
+func TestDispatch_Troubleshoot_NotImplemented(t *testing.T) {
+ // dispatcher with a driver that doesn't implement Troubleshooter
+ // call dispatch("ResourceDriver.Troubleshoot", args)
+ // expect codes.Unimplemented
+}
+
+func TestDispatch_Troubleshoot_Implemented(t *testing.T) {
+ // dispatcher with AppPlatformDriver (or stub) that returns 1 Diagnostic
+ // expect diagnostics map in response
+}
+```
+
+Run: `go test ./internal/plugin/... -run TestDispatch_Troubleshoot -v`
+Expected: 2/2 PASS.
+
+**Step 4: Commit**
+
+```bash
+git add internal/plugin/dispatcher.go internal/plugin/dispatcher_test.go
+git commit -m "plugin: gRPC dispatch for ResourceDriver.Troubleshoot"
+```
+
+---
+
+### Task 13: Additional godo client test coverage
+
+**Files:**
+- Modify: `internal/drivers/app_platform_troubleshoot_test.go` (extend from Task 11)
+
+Add coverage for:
+- Happy path: deployment ACTIVE, no diagnostics returned (or one with `Cause=""`)
+- `app == nil` or `appErr != nil` handled without crash
+- `ref.ProviderID == ""` returns `(nil, nil)`
+- `extractCause` table test with realistic log samples
+
+```go
+func TestExtractCause_TableDriven(t *testing.T) {
+ cases := []struct{ name, in, want string }{
+ {"go panic", "panic: runtime error\nsomething", "panic: runtime error"},
+ {"exit status", "foo\nError: exit status 1\nbar", "Error: exit status 1"},
+ {"empty", "", ""},
+ {"whitespace only", " \n ", ""},
+ {"fallback to last", "random\ngoodbye", "goodbye"},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ if got := extractCause(c.in); got != c.want {
+ t.Errorf("got %q want %q", got, c.want)
+ }
+ })
+ }
+}
+```
+
+Commit:
+
+```bash
+git add internal/drivers/app_platform_troubleshoot_test.go
+git commit -m "drivers: extra Troubleshoot test coverage (edge cases + extractCause table)"
+```
+
+---
+
+### Task 14: Bump workflow dependency to v0.18.10 (blocked by Task 10)
+
+**Files:**
+- Modify: `go.mod`, `go.sum`
+
+**Step 1: Verify Task 10 complete — workflow v0.18.10 tagged**
+
+```bash
+gh release view v0.18.10 --repo GoCodeAlone/workflow --json tagName
+```
+Expected: `{"tagName":"v0.18.10"}`.
+
+**Step 2: Bump**
+
+```bash
+go get github.com/GoCodeAlone/workflow@v0.18.10
+go mod tidy
+```
+
+**Step 3: Build + full test**
+
+```bash
+GOWORK=off go build ./... && GOWORK=off go test -race -short ./...
+```
+
+**Step 4: Commit**
+
+```bash
+git add go.mod go.sum
+git commit -m "deps: bump workflow v0.18.9 → v0.18.10"
+```
+
+---
+
+### Task 15: CHANGELOG.md for DO plugin v0.7.8
+
+Prepend:
+
+```markdown
+## v0.7.8 — 2026-04-24
+
+### Added
+- `AppPlatformDriver.Troubleshoot` implements `interfaces.Troubleshooter` from workflow
+ v0.18.10. On deploy failure, wfctl automatically fetches the most recent deployment(s)
+ and their per-phase logs (pre_deploy, build, deploy, run), synthesizes `[]Diagnostic`
+ entries with extracted root-cause lines, and surfaces them in CI output — no DO
+ console trip required to diagnose failures.
+
+### Changed
+- Depends on workflow v0.18.10 (was v0.18.9).
+```
+
+```bash
+git add CHANGELOG.md
+git commit -m "docs: CHANGELOG v0.7.8"
+```
+
+---
+
+### Task 16: Open PR on workflow-plugin-digitalocean
+
+Same flow as Task 9:
+- DM code-reviewer + spec-reviewer pre-push
+- `git push -u origin feat/v0.7.8-troubleshoot`
+- `gh pr create` with summary + test plan referencing workflow v0.18.10 + design doc
+- `gh pr edit ?? --add-reviewer copilot-pull-request-reviewer`
+- Wait for Copilot, address comments, DM team-lead
+
+---
+
+### Task 17: Tag v0.7.8 (team-lead, post-merge)
+
+```bash
+cd /Users/jon/workspace/workflow-plugin-digitalocean
+git checkout main && git pull
+git tag -a v0.7.8 -m "v0.7.8: Troubleshoot interface (deploy-log observability via wfctl v0.18.10)"
+git push origin v0.7.8
+```
+
+Verify release workflow publishes binaries + manifest update.
+
+---
+
+## Phase 3 — BMW consumer bump
+
+### Task 18: Bump BMW pins (impl-bmw-2 owner; blocked by Task 17)
+
+**Repo:** `/Users/jon/workspace/buymywishlist`, new branch `chore/bump-wfctl-v0.18.10-do-v0.7.8`.
+
+**Files:**
+- Modify: `.github/workflows/deploy.yml` (find `uses: GoCodeAlone/setup-wfctl@v1` with `with: version:` line OR env `WFCTL_VERSION`)
+- Modify: `.github/workflows/deploy.yml` (find DO plugin version pin — grep for `workflow-plugin-digitalocean`)
+
+**Step 1: Grep current pins**
+
+```bash
+grep -rn "v0\.18\.9\|workflow-plugin-digitalocean.*v0\.7\.7" .github/workflows/ infra.yaml 2>/dev/null
+```
+
+**Step 2: Update each occurrence**
+
+- `v0.18.9` → `v0.18.10`
+- `workflow-plugin-digitalocean` `v0.7.7` → `v0.7.8`
+
+**Step 3: Commit**
+
+```bash
+git add .github/workflows/ infra.yaml
+git commit -m "chore: bump setup-wfctl v0.18.9 → v0.18.10 + DO plugin v0.7.7 → v0.7.8 (observability)"
+```
+
+**Step 4: DM code-reviewer, push, open PR, Copilot review, DM team-lead**
+
+Same flow as every other BMW bump. Additive change only; no deploy.yml logic edits.
+
+---
+
+### Task 19: Merge BMW PR (team-lead)
+
+Standard admin-merge once CI green + Copilot clean + any comments addressed.
+
+After merge, BMW deploys on main will use wfctl v0.18.10 + DO plugin v0.7.8 automatically. The NEXT deploy failure will surface full troubleshooting context in the GHA run output without anyone opening the DO console.
+
+---
+
+## Success verification
+
+After Task 19:
+
+1. **BMW retry** (with Stream B migrations still broken — tests the observability path):
+ ```bash
+ gh workflow run teardown-staging.yml --ref main --repo GoCodeAlone/buymywishlist -f confirm=yes
+ # wait for teardown
+ gh run rerun --repo GoCodeAlone/buymywishlist
+ ```
+2. **Expected outcome**: deploy fails (Stream B not yet landed), but the GHA run log now contains a `Troubleshoot: bmw-staging` group block with the `pre_deploy` error (`workflow-migrate up: first .: file does not exist`) visible without clicking into the DO console.
+3. **After Stream B lands**: retry one more time. Both streams together → deploy succeeds → /healthz green → auto-promote to prod.
+
+## Non-goals (explicit)
+
+- Generic `IaCProvider.StreamLogs` interface — deferred to v0.19.0.
+- AWS / GCP / Azure / tofu Troubleshoot implementations — DO only for v0.7.8.
+- Real-time streaming log display during happy path — only on failure.
+- Log redaction beyond existing `SensitiveKeys()` — plugin-side concern.
+- State-heal for non-UUID ProviderIDs (task #67) — orthogonal workstream.
diff --git a/interfaces/iac_resource_driver.go b/interfaces/iac_resource_driver.go
index baaa34b44..a4b91f797 100644
--- a/interfaces/iac_resource_driver.go
+++ b/interfaces/iac_resource_driver.go
@@ -3,6 +3,7 @@ package interfaces
import (
"context"
"errors"
+ "time"
)
// Sentinel errors for common IaC resource operation response categories.
@@ -60,3 +61,27 @@ type HealthResult struct {
Healthy bool `json:"healthy"`
Message string `json:"message,omitempty"`
}
+
+// Diagnostic is a single troubleshooting finding returned by a Troubleshooter.
+// It describes a recent provider-side event (deployment, job run, etc.) with
+// enough context to understand why a health check failed without visiting the
+// provider's console.
+type Diagnostic struct {
+ ID string `json:"id"` // provider-side identifier (e.g. deployment ID)
+ Phase string `json:"phase"` // terminal or current phase
+ Cause string `json:"cause"` // human-readable root cause or error summary
+ At time.Time `json:"at"` // when the event was created or last updated
+ Detail string `json:"detail,omitempty"` // optional verbose tail (log excerpt, stack)
+}
+
+// Troubleshooter is an optional interface that ResourceDrivers may implement.
+// wfctl calls Troubleshoot automatically when a health-check poll times out or
+// a deploy operation returns a generic error, surfacing provider-side context
+// that would otherwise require visiting the provider's web console.
+//
+// Implementations should return the N most recent relevant events (deployments,
+// job runs, etc.) in reverse-chronological order. Returning an error is
+// non-fatal — wfctl logs it and continues with the original failure message.
+type Troubleshooter interface {
+ Troubleshoot(ctx context.Context, ref ResourceRef, failureMsg string) ([]Diagnostic, error)
+}
diff --git a/interfaces/troubleshooter_test.go b/interfaces/troubleshooter_test.go
new file mode 100644
index 000000000..94deb8e86
--- /dev/null
+++ b/interfaces/troubleshooter_test.go
@@ -0,0 +1,53 @@
+package interfaces
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+ "time"
+)
+
+// compile-time check: Troubleshooter is a valid interface.
+var _ Troubleshooter = (*fakeTroubleshooter)(nil)
+
+type fakeTroubleshooter struct{}
+
+func (fakeTroubleshooter) Troubleshoot(_ context.Context, _ ResourceRef, _ string) ([]Diagnostic, error) {
+ return nil, nil
+}
+
+func TestDiagnostic_JSONRoundtrip(t *testing.T) {
+ d := Diagnostic{
+ ID: "dep-abc",
+ Phase: "pre_deploy",
+ Cause: "exit 1",
+ At: time.Now().UTC().Truncate(time.Second),
+ Detail: "line1\nline2",
+ }
+
+ data, err := json.Marshal(d)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+
+ var got Diagnostic
+ if err := json.Unmarshal(data, &got); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+
+ if got.ID != d.ID {
+ t.Errorf("ID: got %q, want %q", got.ID, d.ID)
+ }
+ if got.Phase != d.Phase {
+ t.Errorf("Phase: got %q, want %q", got.Phase, d.Phase)
+ }
+ if got.Cause != d.Cause {
+ t.Errorf("Cause: got %q, want %q", got.Cause, d.Cause)
+ }
+ if got.Detail != d.Detail {
+ t.Errorf("Detail: got %q, want %q", got.Detail, d.Detail)
+ }
+ if !got.At.Equal(d.At) {
+ t.Errorf("At: got %v, want %v", got.At, d.At)
+ }
+}