Firing allowlist on RoutineStatus
apps/backend/internal/office/models/enums.go ↗The type gates every fire path with one byte-exact allowlist check.
Allowlist
type RoutineStatus string
const (
RoutineStatusActive RoutineStatus = "active"
RoutineStatusPaused RoutineStatus = "paused"
RoutineStatusArchived RoutineStatus = "archived"
)
func (s RoutineStatus) CanFire() bool {
return s == RoutineStatusActive || s == ""
}
Cron gate and suppression path
apps/backend/internal/office/routines/service.go ↗The cron worker reads the routine first and suppresses non-firing slots without claiming.
Gate before claim
routine, err := s.GetRoutineFromConfig(ctx, trigger.RoutineID)
if err != nil {
return nil
}
if !models.RoutineStatus(routine.Status).CanFire() {
return s.suppressCronSlot(ctx, trigger, routine, now)
}
claimed, err := s.repo.ClaimTrigger(ctx, trigger.ID, *trigger.NextRunAt)
Suppression advance
next, err := s.computeSuppressionCursor(trigger, now)
if err != nil {
return nil
}
advanced, err := s.repo.AdvanceTriggerWithoutFiring(ctx, trigger.ID, *trigger.NextRunAt, next)
if !advanced {
return nil
}
s.logger.Info("routine slot suppressed",
zap.String("routine_id", routine.ID),
zap.String("status", routine.Status))
Cursor advance without fire evidence
apps/backend/internal/office/repository/sqlite/routines.go ↗The write moves next_run_at by compare-and-set and never touches last_fired_at.
Suppression write
func (r *Repository) AdvanceTriggerWithoutFiring(
ctx context.Context, triggerID string, oldNextRunAt, newNextRunAt time.Time,
) (bool, error) {
res, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE office_routine_triggers
SET next_run_at = ?, updated_at = ?
WHERE id = ? AND next_run_at = ?
`), newNextRunAt, time.Now().UTC(), triggerID, oldNextRunAt)
if err != nil {
return false, err
}
rows, err := res.RowsAffected()
return rows > 0, err
}
Manual and webhook refusal with 409
apps/backend/internal/office/routines/handler.go ↗Both routes refuse non-firing routines with one shared 409 body.
Manual refusal
run, err := h.svc.FireManual(c.Request.Context(), c.Param("id"), req.Variables)
if err != nil {
var notFiring *RoutineNotFiringError
if errors.As(err, ¬Firing) {
c.JSON(http.StatusConflict, routineNotFiringBody(notFiring.Status))
return
}
writeDispatchError(c, err)
return
}
Webhook gate and body
if !models.RoutineStatus(routine.Status).CanFire() {
c.JSON(http.StatusConflict, routineNotFiringBody(routine.Status))
return
}
func routineNotFiringBody(status string) gin.H {
return gin.H{
"error": fmt.Sprintf("routine cannot fire: status is %q", status),
"error_code": RoutineNotFiringErrorCode,
"status": status,
}
}
Web UI mirror and refusal copy
apps/web/app/office/lib/routine-status.ts ↗The UI hides next-fire times and maps the 409 code to localized copy.
Firing check
export function isRoutineFiring(status: string): boolean {
return status === "active" || status === "";
}
Refusal message
export const ROUTINE_NOT_FIRING_ERROR_CODE = "routine_not_firing";
export function routineNotFiringMessage(error: unknown, t: Translate, fallbackKey: string): string {
if (isRoutineNotFiringError(error)) {
return t("office:routineNotFiring", { status: refusedStatus(error, t) });
}
if (error instanceof Error && error.message.trim()) return error.message;
return t(fallbackKey);
}
Row gating
const isActive = isRoutineFiring(routine.status);
const nextFire = isActive ? nextFireText(t, triggers) : "";