package workflows import ( "context" "errors" "fmt" "log/slog" "go.temporal.io/api/serviceerror" "go.temporal.io/sdk/client" ) // DormantPauseNote marks a schedule pause as made by an unconfigured boot. // ResumeDormantSchedule resumes ONLY pauses carrying this exact note, so an // operator's deliberate pause (any other note, including one the system // overwrote) survives restarts. Changing this string strands schedules // paused under the old wording — treat it as frozen. const DormantPauseNote = "paused by member-console: integration not configured or disabled this boot; a configured boot resumes it automatically" const dormantResumeNote = "resumed by member-console: integration configured" // PauseLeftoverSchedule pauses scheduleID if it exists and is running. // Integrations call this when they boot unconfigured/disabled: Temporal // schedules are durable, so a schedule ensured by an earlier configured // boot would otherwise keep firing workflows into a worker whose provider // client has no configuration, failing every attempt at every interval, // indefinitely. An absent schedule is a no-op; an already-paused schedule // is left untouched whether the pause was ours or an operator's. func PauseLeftoverSchedule(ctx context.Context, c client.Client, scheduleID string, logger *slog.Logger) error { handle := c.ScheduleClient().GetHandle(ctx, scheduleID) desc, err := handle.Describe(ctx) if err != nil { if isScheduleAbsent(err) { return nil } return fmt.Errorf("describe schedule %s: %w", scheduleID, err) } if !shouldPauseLeftover(desc.Schedule.State) { return nil } if err := handle.Pause(ctx, client.SchedulePauseOptions{Note: DormantPauseNote}); err != nil { return fmt.Errorf("pause schedule %s: %w", scheduleID, err) } logger.Info("paused leftover schedule of unconfigured integration", slog.String("scheduleID", scheduleID)) return nil } // ResumeDormantSchedule unpauses scheduleID only when its pause note is // DormantPauseNote — a pause made by PauseLeftoverSchedule on an earlier // unconfigured boot. Integrations call this after ensuring their schedule // on a configured boot. A pause carrying any other note is treated as // operator intent and left in place (logged, so a configured-but-silent // integration is diagnosable). func ResumeDormantSchedule(ctx context.Context, c client.Client, scheduleID string, logger *slog.Logger) error { handle := c.ScheduleClient().GetHandle(ctx, scheduleID) desc, err := handle.Describe(ctx) if err != nil { if isScheduleAbsent(err) { return nil } return fmt.Errorf("describe schedule %s: %w", scheduleID, err) } state := desc.Schedule.State if state == nil || !state.Paused { return nil } if !shouldResumeDormant(state) { logger.Info("schedule is paused with a non-dormancy note; leaving it paused", slog.String("scheduleID", scheduleID), slog.String("note", state.Note)) return nil } if err := handle.Unpause(ctx, client.ScheduleUnpauseOptions{Note: dormantResumeNote}); err != nil { return fmt.Errorf("unpause schedule %s: %w", scheduleID, err) } logger.Info("resumed schedule paused by an earlier unconfigured boot", slog.String("scheduleID", scheduleID)) return nil } // shouldPauseLeftover and shouldResumeDormant hold the decision logic as // pure functions: the Schedule API has no testsuite fake (same constraint // as the fedwiki ScheduleManager tests), so these are the reachable, // meaningful units. func shouldPauseLeftover(state *client.ScheduleState) bool { return state == nil || !state.Paused } func shouldResumeDormant(state *client.ScheduleState) bool { return state != nil && state.Paused && state.Note == DormantPauseNote } // isScheduleAbsent mirrors the NotFound-only classification the schedule // managers use: only Temporal's *serviceerror.NotFound means "no schedule"; // anything else is surfaced rather than swallowed. func isScheduleAbsent(err error) bool { var nf *serviceerror.NotFound return errors.As(err, &nf) }