Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 22 additions & 39 deletions internal/cli/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -2543,53 +2543,36 @@ func saveRepoConfig(ctx context.Context, client forge.Client, printer *ui.Printe
// config.yaml push, waits for it to complete, and prints any PR URLs from its
// annotations.
func awaitRepoMaintenance(ctx context.Context, client forge.Client, printer *ui.Printer, org string, dispatchTime time.Time) {
awaitRepoMaintenanceWithInterval(ctx, client, printer, org, dispatchTime, 5*time.Second, 36)
awaitRepoMaintenanceWithConfig(ctx, client, printer, org, dispatchTime, layers.DefaultPollConfig())
}

func awaitRepoMaintenanceWithInterval(ctx context.Context, client forge.Client, printer *ui.Printer, org string, dispatchTime time.Time, pollInterval time.Duration, maxAttempts int) {
// Map legacy interval/attempts to a PollConfig and delegate.
cfg := layers.PollConfig{
InitialInterval: pollInterval,
MaxInterval: pollInterval, // no backoff growth for legacy callers
Timeout: pollInterval * time.Duration(maxAttempts),
}
awaitRepoMaintenanceWithConfig(ctx, client, printer, org, dispatchTime, cfg)
}

func awaitRepoMaintenanceWithConfig(ctx context.Context, client forge.Client, printer *ui.Printer, org string, dispatchTime time.Time, cfg layers.PollConfig) {
printer.Blank()
printer.StepStart("Watching repo-maintenance workflow")

// Poll for a workflow run created after dispatchTime.
var run *forge.WorkflowRun
for attempt := range maxAttempts {
select {
case <-ctx.Done():
printer.StepWarn("context cancelled while waiting for workflow")
return
case <-time.After(pollInterval):
}

runs, err := client.ListWorkflowRuns(ctx, org, forge.ConfigRepoName, "repo-maintenance.yml")
if err != nil {
printer.StepInfo(fmt.Sprintf("waiting for workflow run (attempt %d)...", attempt+1))
continue
}
run, err := layers.AwaitWorkflowCompletion(
ctx, client, org, forge.ConfigRepoName, "repo-maintenance.yml",
dispatchTime, cfg,
func(msg string) { printer.StepInfo(msg) },
)

for i := range runs {
r := &runs[i]
runTime, parseErr := time.Parse(time.RFC3339, r.CreatedAt)
if parseErr != nil {
continue
}
if runTime.Before(dispatchTime) {
continue
}
if r.Status == "completed" {
run = r
break
}
printer.StepInfo(fmt.Sprintf("workflow run: %s (%s)", r.HTMLURL, r.Status))
break // found our run, keep waiting
}
if run != nil {
break
if err != nil {
if ctx.Err() != nil {
printer.StepWarn("context cancelled while waiting for workflow")
} else {
printer.StepWarn("timed out waiting for repo-maintenance workflow")
printer.StepInfo("check the repo-maintenance workflow in .fullsend for results")
}
}

if run == nil {
printer.StepWarn("timed out waiting for repo-maintenance workflow")
printer.StepInfo("check the repo-maintenance workflow in .fullsend for results")
return
}

Expand Down
38 changes: 6 additions & 32 deletions internal/layers/enrollment.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,39 +105,13 @@ func (l *EnrollmentLayer) Install(ctx context.Context) error {
}

// awaitWorkflowRun polls for a repo-maintenance workflow run created after
// dispatchTime and waits for it to complete.
// dispatchTime and waits for it to complete, using exponential backoff.
func (l *EnrollmentLayer) awaitWorkflowRun(ctx context.Context, dispatchTime time.Time) (*forge.WorkflowRun, error) {
for attempt := range 36 { // 3 minutes max
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(5 * time.Second):
}

runs, err := l.client.ListWorkflowRuns(ctx, l.org, forge.ConfigRepoName, repoMaintenanceWorkflow)
if err != nil {
l.ui.StepInfo(fmt.Sprintf("waiting for workflow run (attempt %d)...", attempt+1))
continue
}

for i := range runs {
run := &runs[i]
runTime, parseErr := time.Parse(time.RFC3339, run.CreatedAt)
if parseErr != nil {
continue
}
if runTime.Before(dispatchTime) {
continue
}

if run.Status == "completed" {
return run, nil
}
l.ui.StepInfo(fmt.Sprintf("workflow run: %s (%s)", run.HTMLURL, run.Status))
break // found our run, keep waiting
}
}
return nil, fmt.Errorf("timed out waiting for repo-maintenance workflow")
return AwaitWorkflowCompletion(
ctx, l.client, l.org, forge.ConfigRepoName, repoMaintenanceWorkflow,
dispatchTime, DefaultPollConfig(),
func(msg string) { l.ui.StepInfo(msg) },
)
}

// showWorkflowLogs fetches and displays workflow run logs locally so the user
Expand Down
107 changes: 107 additions & 0 deletions internal/layers/workflowpoll.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package layers

import (
"context"
"fmt"
"time"

"github.com/fullsend-ai/fullsend/internal/forge"
)

// PollConfig configures workflow polling behavior.
type PollConfig struct {
// InitialInterval is the delay before the first poll and the starting
// interval for exponential backoff.
InitialInterval time.Duration

// MaxInterval caps how long the backoff can grow between polls.
MaxInterval time.Duration

// Timeout is the maximum wall-clock time to wait for the workflow to
// complete before returning an error.
Timeout time.Duration
}

// DefaultPollConfig returns the default polling configuration:
// 5s initial interval, 15s max interval, 3-minute timeout.
func DefaultPollConfig() PollConfig {
return PollConfig{
InitialInterval: 5 * time.Second,
MaxInterval: 15 * time.Second,
Timeout: 3 * time.Minute,
}
}

// ProgressFunc is an optional callback invoked with status messages during
// polling. Callers can use it to print progress to users.
type ProgressFunc func(msg string)

// AwaitWorkflowCompletion polls for a workflow run created after dispatchTime
// and waits for it to complete, using exponential backoff. It returns the
// completed run or an error if the context is cancelled or the timeout expires.
//
// The onProgress callback, if non-nil, is called with status messages during
// polling (e.g., "waiting for workflow run" or intermediate run status).
func AwaitWorkflowCompletion(
parentCtx context.Context,
client forge.Client,
org, repo, workflowFile string,
dispatchTime time.Time,
cfg PollConfig,
onProgress ProgressFunc,
) (*forge.WorkflowRun, error) {
ctx, cancel := context.WithTimeout(parentCtx, cfg.Timeout)
defer cancel()

interval := cfg.InitialInterval

for attempt := 0; ; attempt++ {
select {
case <-ctx.Done():
if parentCtx.Err() != nil {
return nil, parentCtx.Err()
}
return nil, fmt.Errorf("timed out waiting for %s workflow", workflowFile)
case <-time.After(interval):
}

runs, err := client.ListWorkflowRuns(ctx, org, repo, workflowFile)
if err != nil {
if onProgress != nil {
onProgress(fmt.Sprintf("waiting for workflow run (attempt %d)...", attempt+1))
}
interval = nextInterval(interval, cfg.MaxInterval)
continue
}

for i := range runs {
run := &runs[i]
runTime, parseErr := time.Parse(time.RFC3339, run.CreatedAt)
if parseErr != nil {
continue
}
if runTime.Before(dispatchTime) {
continue
}

if run.Status == "completed" {
return run, nil
}
if onProgress != nil {
onProgress(fmt.Sprintf("workflow run: %s (%s)", run.HTMLURL, run.Status))
}
break // found our run, keep waiting
}

interval = nextInterval(interval, cfg.MaxInterval)
}
}

// nextInterval doubles the current interval, capping at maxInterval.
func nextInterval(current, maxInterval time.Duration) time.Duration {
next := current * 2
if next > maxInterval {
return maxInterval
}
return next
}
Loading
Loading